Run the hub as web and worker processes, singletons under a leader lock e57765dee61a
oxygenhub serve --role web,worker ([server] roles, OXYHUB_SERVER_ROLES; every role by default). web serves the router and owns the transparency log (appends and its checkpointer); worker runs the background tasks and serves only /health and /metrics, opening the log read-only. Each singleton task runs under leader::run: on PostgreSQL a session advisory lock keyed by a BLAKE3 hash of schema and name, on one connection of the process's own, retried every 2 s and checked every 2 s; a lost connection stops the tasks and contends again. Always held on SQLite. /health reports roles and leading. One web process per data directory until #274. Closes #272
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key badbd6dd081f
- When
- · 2026-10-19 22:47 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
e57765dee61a999dfb499c229a7903bcf3102d8976fb0abaf55b2314d777b705- Depends on
- 070a7b64b368 0843f23ec51c 0e04dfc20211 18e381ea88c2 1c4ad601ef9d 2a83f5303458 2cbf6f348595 348f8c4baccf 3556d08e17f9 4d2e44d79b03 4f40eed0588f 6b195ed3b637 8a3334b7a364 93501da2a162 93be9ce4653e 965b950f9b96 9a8e3b4419e0 a7e6e8e6094c b210727578e8 b5668108487f b64e2537d702 bff9106edcba c8684d44d549 c8df33a6829f c92a99490cba d23464c0ac60 d7d182467382 e97500b70f9a f677d6270298
- Depended on by
- 70c3d02998a2 439ecd8708a1 9e8cbfc3ad00 dd327d76062d 356183b71fab ec84907043c2 cb7b3cd45dc0 33d98147511b
- 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
- 49 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/src/leader.rs +338 −0
| 1 | + | //! Leader locks (docs/roles.md, #272): a background task that must run |
|
| 2 | + | //! once across every process sharing the hub's database runs only in the |
|
| 3 | + | //! process holding a lock named for it. |
|
| 4 | + | //! |
|
| 5 | + | //! On PostgreSQL the lock is a session advisory lock |
|
| 6 | + | //! (`pg_try_advisory_lock`) on one connection of this process's own, kept |
|
| 7 | + | //! apart from the pool for as long as the process runs. A process that does |
|
| 8 | + | //! not hold a task's lock asks again every [`RETRY`]. One that holds it |
|
| 9 | + | //! checks its connection every [`CHECK`]; if the connection has gone (the |
|
| 10 | + | //! database restarted, the network dropped, the session was ended), the |
|
| 11 | + | //! server has freed the lock, so the task is stopped (its future dropped) |
|
| 12 | + | //! and the process contends again on a new connection. A process that dies |
|
| 13 | + | //! closes its connection, freeing its locks, and another takes over within |
|
| 14 | + | //! seconds. Each task reads its work from the database, so a successor |
|
| 15 | + | //! picking up where it stopped is safe. |
|
| 16 | + | //! |
|
| 17 | + | //! On SQLite (one machine, one process by definition) every lock is held. |
|
| 18 | + | ||
| 19 | + | use crate::Shared; |
|
| 20 | + | use std::collections::BTreeSet; |
|
| 21 | + | use std::future::Future; |
|
| 22 | + | use std::sync::Mutex; |
|
| 23 | + | use std::time::Duration; |
|
| 24 | + | ||
| 25 | + | /// How often a process that does not hold a lock asks for it again. |
|
| 26 | + | pub const RETRY: Duration = Duration::from_secs(2); |
|
| 27 | + | ||
| 28 | + | /// How often a holder makes sure its connection, and so its lock, is still |
|
| 29 | + | /// there; and how long it waits for the database's answer. |
|
| 30 | + | pub const CHECK: Duration = Duration::from_secs(2); |
|
| 31 | + | ||
| 32 | + | /// This process's background tasks and the locks it holds. |
|
| 33 | + | #[derive(Default)] |
|
| 34 | + | pub struct Leader { |
|
| 35 | + | /// Every background task this process started, led or not. |
|
| 36 | + | tasks: Mutex<BTreeSet<&'static str>>, |
|
| 37 | + | /// The singleton tasks running here now: the locks this process holds. |
|
| 38 | + | leading: Mutex<BTreeSet<&'static str>>, |
|
| 39 | + | /// The loops contending for locks, stopped by [`Leader::resign`]. |
|
| 40 | + | contenders: Mutex<Vec<tokio::task::AbortHandle>>, |
|
| 41 | + | #[cfg(feature = "postgres")] |
|
| 42 | + | session: pg::Session, |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | impl Leader { |
|
| 46 | + | /// The background tasks this process started, by name. |
|
| 47 | + | pub fn tasks(&self) -> Vec<&'static str> { |
|
| 48 | + | self.tasks.lock().unwrap_or_else(|p| p.into_inner()).iter().copied().collect() |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | /// The leader locks this process holds: the singleton tasks running here. |
|
| 52 | + | pub fn leading(&self) -> Vec<&'static str> { |
|
| 53 | + | self.leading.lock().unwrap_or_else(|p| p.into_inner()).iter().copied().collect() |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// Record a background task that needs no lock (it is safe anywhere). |
|
| 57 | + | pub fn note(&self, name: &'static str) { |
|
| 58 | + | self.tasks.lock().unwrap_or_else(|p| p.into_inner()).insert(name); |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// Stop every singleton task here and give up their locks, as a process |
|
| 62 | + | /// that stops does: another process takes them over. |
|
| 63 | + | pub async fn resign(&self) { |
|
| 64 | + | for c in self.contenders.lock().unwrap_or_else(|p| p.into_inner()).drain(..) { |
|
| 65 | + | c.abort(); |
|
| 66 | + | } |
|
| 67 | + | #[cfg(feature = "postgres")] |
|
| 68 | + | self.session.close().await; |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | /// The process id of the database session holding this process's |
|
| 72 | + | /// locks, if it has one (for tests that end it). |
|
| 73 | + | #[cfg(feature = "postgres")] |
|
| 74 | + | pub async fn session_pid(&self) -> Option<i32> { |
|
| 75 | + | self.session.pid().await |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | // Taking, checking and giving back a lock: on PostgreSQL through the |
|
| 80 | + | // session; on SQLite every lock is this process's. |
|
| 81 | + | #[cfg(feature = "postgres")] |
|
| 82 | + | impl Leader { |
|
| 83 | + | /// Take `name`'s lock now if it is free: the session's generation it |
|
| 84 | + | /// was taken in, which [`Leader::held`] checks against. |
|
| 85 | + | async fn acquire(&self, app: &Shared, name: &'static str) -> Option<u64> { |
|
| 86 | + | self.session.acquire(&app.db, name).await |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// Whether a lock taken in generation `term` is still held. |
|
| 90 | + | async fn held(&self, term: u64) -> bool { |
|
| 91 | + | self.session.held(term).await |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /// Give `name`'s lock back, its task done. |
|
| 95 | + | async fn release(&self, name: &'static str, term: u64) { |
|
| 96 | + | self.session.release(name, term).await |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | #[cfg(not(feature = "postgres"))] |
|
| 101 | + | impl Leader { |
|
| 102 | + | async fn acquire(&self, _app: &Shared, _name: &'static str) -> Option<u64> { |
|
| 103 | + | Some(0) |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | async fn held(&self, _term: u64) -> bool { |
|
| 107 | + | true |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | async fn release(&self, _name: &'static str, _term: u64) {} |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | /// Marks a task as led here while it runs; dropping it (the task ended, |
|
| 114 | + | /// was stopped, or the loop was aborted) unmarks it. |
|
| 115 | + | struct Leading<'a>(&'a Leader, &'static str); |
|
| 116 | + | ||
| 117 | + | impl<'a> Leading<'a> { |
|
| 118 | + | fn new(leader: &'a Leader, name: &'static str) -> Self { |
|
| 119 | + | leader.leading.lock().unwrap_or_else(|p| p.into_inner()).insert(name); |
|
| 120 | + | Leading(leader, name) |
|
| 121 | + | } |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | impl Drop for Leading<'_> { |
|
| 125 | + | fn drop(&mut self) { |
|
| 126 | + | self.0.leading.lock().unwrap_or_else(|p| p.into_inner()).remove(self.1); |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// Run the singleton task `name` in the background, only while this |
|
| 131 | + | /// process holds its lock. `task` makes the task's future; it is made |
|
| 132 | + | /// again each time the lock is taken after being lost. A task that ends by |
|
| 133 | + | /// itself gives its lock back and is not run again. |
|
| 134 | + | pub fn run<F, Fut>(app: &Shared, name: &'static str, task: F) |
|
| 135 | + | where |
|
| 136 | + | F: Fn(Shared) -> Fut + Send + 'static, |
|
| 137 | + | Fut: Future<Output = ()> + Send + 'static, |
|
| 138 | + | { |
|
| 139 | + | app.leader.note(name); |
|
| 140 | + | let handle = tokio::spawn(contend(app.clone(), name, task)); |
|
| 141 | + | app.leader.contenders.lock().unwrap_or_else(|p| p.into_inner()).push(handle.abort_handle()); |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | async fn contend<F, Fut>(app: Shared, name: &'static str, task: F) |
|
| 145 | + | where |
|
| 146 | + | F: Fn(Shared) -> Fut, |
|
| 147 | + | Fut: Future<Output = ()>, |
|
| 148 | + | { |
|
| 149 | + | let leader = &app.leader; |
|
| 150 | + | let mut waiting = false; |
|
| 151 | + | loop { |
|
| 152 | + | let Some(term) = leader.acquire(&app, name).await else { |
|
| 153 | + | if !waiting { |
|
| 154 | + | tracing::info!("leader: {name} runs elsewhere; standing by"); |
|
| 155 | + | waiting = true; |
|
| 156 | + | } |
|
| 157 | + | tokio::time::sleep(RETRY).await; |
|
| 158 | + | continue; |
|
| 159 | + | }; |
|
| 160 | + | if waiting { |
|
| 161 | + | tracing::info!("leader: took over {name}"); |
|
| 162 | + | waiting = false; |
|
| 163 | + | } |
|
| 164 | + | let _leading = Leading::new(leader, name); |
|
| 165 | + | let work = task(app.clone()); |
|
| 166 | + | tokio::pin!(work); |
|
| 167 | + | let finished = loop { |
|
| 168 | + | tokio::select! { |
|
| 169 | + | _ = &mut work => break true, |
|
| 170 | + | _ = tokio::time::sleep(CHECK) => { |
|
| 171 | + | if !leader.held(term).await { |
|
| 172 | + | break false; |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | } |
|
| 176 | + | }; |
|
| 177 | + | if finished { |
|
| 178 | + | leader.release(name, term).await; |
|
| 179 | + | return; |
|
| 180 | + | } |
|
| 181 | + | // The future is dropped here: the task stops where it was. |
|
| 182 | + | tracing::warn!("leader: lost the lock for {name}; stopped it, contending again"); |
|
| 183 | + | } |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | #[cfg(feature = "postgres")] |
|
| 187 | + | mod pg { |
|
| 188 | + | use super::CHECK; |
|
| 189 | + | use crate::db::Pool; |
|
| 190 | + | use sqlx::postgres::PgConnection; |
|
| 191 | + | use sqlx::Connection; |
|
| 192 | + | use std::time::Instant; |
|
| 193 | + | ||
| 194 | + | /// The connection holding this process's locks. |
|
| 195 | + | struct Conn { |
|
| 196 | + | conn: PgConnection, |
|
| 197 | + | /// The schema the hub's tables are in: two hubs in two schemas of |
|
| 198 | + | /// one database are two hubs, and do not share locks. |
|
| 199 | + | schema: String, |
|
| 200 | + | pid: i32, |
|
| 201 | + | /// When the database last answered on it. |
|
| 202 | + | checked: Instant, |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | #[derive(Default)] |
|
| 206 | + | pub(super) struct Session { |
|
| 207 | + | conn: tokio::sync::Mutex<Option<Conn>>, |
|
| 208 | + | /// Counts connections lost: a lock taken in an earlier generation |
|
| 209 | + | /// went with its connection. |
|
| 210 | + | term: std::sync::atomic::AtomicU64, |
|
| 211 | + | closed: std::sync::atomic::AtomicBool, |
|
| 212 | + | } |
|
| 213 | + | ||
| 214 | + | /// The lock's key: a stable 64-bit hash of the schema and the name. |
|
| 215 | + | pub(super) fn key(schema: &str, name: &str) -> i64 { |
|
| 216 | + | let h = blake3::hash(format!("oxygenhub leader\0{schema}\0{name}").as_bytes()); |
|
| 217 | + | i64::from_le_bytes(h.as_bytes()[..8].try_into().expect("eight bytes")) |
|
| 218 | + | } |
|
| 219 | + | ||
| 220 | + | impl Session { |
|
| 221 | + | fn term(&self) -> u64 { |
|
| 222 | + | self.term.load(std::sync::atomic::Ordering::SeqCst) |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /// The connection is gone, and every lock with it. |
|
| 226 | + | fn lose(&self, slot: &mut Option<Conn>) { |
|
| 227 | + | if slot.take().is_some() { |
|
| 228 | + | self.term.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
|
| 229 | + | } |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | async fn connect(db: &Pool) -> anyhow::Result<Conn> { |
|
| 233 | + | let options = db.connect_options(); |
|
| 234 | + | let mut conn = PgConnection::connect_with(&options).await?; |
|
| 235 | + | let (schema, pid): (String, i32) = |
|
| 236 | + | sqlx::query_as("SELECT CAST(current_schema() AS TEXT), pg_backend_pid()").fetch_one(&mut conn).await?; |
|
| 237 | + | Ok(Conn { conn, schema, pid, checked: Instant::now() }) |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | pub(super) async fn acquire(&self, db: &Pool, name: &'static str) -> Option<u64> { |
|
| 241 | + | let mut slot = self.conn.lock().await; |
|
| 242 | + | if self.closed.load(std::sync::atomic::Ordering::SeqCst) { |
|
| 243 | + | return None; |
|
| 244 | + | } |
|
| 245 | + | if slot.is_none() { |
|
| 246 | + | match tokio::time::timeout(CHECK * 5, Self::connect(db)).await { |
|
| 247 | + | Ok(Ok(c)) => *slot = Some(c), |
|
| 248 | + | Ok(Err(e)) => { |
|
| 249 | + | tracing::warn!("leader: connecting for {name}'s lock: {e}"); |
|
| 250 | + | return None; |
|
| 251 | + | } |
|
| 252 | + | Err(_) => { |
|
| 253 | + | tracing::warn!("leader: connecting for {name}'s lock: timed out"); |
|
| 254 | + | return None; |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | let term = self.term(); |
|
| 259 | + | let c = slot.as_mut()?; |
|
| 260 | + | let ask = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)").bind(key(&c.schema, name)); |
|
| 261 | + | match tokio::time::timeout(CHECK, ask.fetch_one(&mut c.conn)).await { |
|
| 262 | + | Ok(Ok(got)) => { |
|
| 263 | + | c.checked = Instant::now(); |
|
| 264 | + | got.then_some(term) |
|
| 265 | + | } |
|
| 266 | + | Ok(Err(e)) => { |
|
| 267 | + | tracing::warn!("leader: asking for {name}'s lock: {e}"); |
|
| 268 | + | self.lose(&mut slot); |
|
| 269 | + | None |
|
| 270 | + | } |
|
| 271 | + | Err(_) => { |
|
| 272 | + | tracing::warn!("leader: asking for {name}'s lock: timed out"); |
|
| 273 | + | self.lose(&mut slot); |
|
| 274 | + | None |
|
| 275 | + | } |
|
| 276 | + | } |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | pub(super) async fn held(&self, term: u64) -> bool { |
|
| 280 | + | let mut slot = self.conn.lock().await; |
|
| 281 | + | if self.term() != term { |
|
| 282 | + | return false; |
|
| 283 | + | } |
|
| 284 | + | let Some(c) = slot.as_mut() else { return false }; |
|
| 285 | + | // One answer serves every task's check made soon after it. |
|
| 286 | + | if c.checked.elapsed() < CHECK / 2 { |
|
| 287 | + | return true; |
|
| 288 | + | } |
|
| 289 | + | let ping = sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&mut c.conn); |
|
| 290 | + | match tokio::time::timeout(CHECK, ping).await { |
|
| 291 | + | Ok(Ok(_)) => { |
|
| 292 | + | c.checked = Instant::now(); |
|
| 293 | + | true |
|
| 294 | + | } |
|
| 295 | + | _ => { |
|
| 296 | + | tracing::warn!("leader: the connection holding the locks is gone"); |
|
| 297 | + | self.lose(&mut slot); |
|
| 298 | + | false |
|
| 299 | + | } |
|
| 300 | + | } |
|
| 301 | + | } |
|
| 302 | + | ||
| 303 | + | pub(super) async fn release(&self, name: &'static str, term: u64) { |
|
| 304 | + | let mut slot = self.conn.lock().await; |
|
| 305 | + | if self.term() != term { |
|
| 306 | + | return; |
|
| 307 | + | } |
|
| 308 | + | let Some(c) = slot.as_mut() else { return }; |
|
| 309 | + | let unlock = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)").bind(key(&c.schema, name)); |
|
| 310 | + | if !matches!(tokio::time::timeout(CHECK, unlock.fetch_one(&mut c.conn)).await, Ok(Ok(_))) { |
|
| 311 | + | self.lose(&mut slot); |
|
| 312 | + | } |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | pub(super) async fn close(&self) { |
|
| 316 | + | self.closed.store(true, std::sync::atomic::Ordering::SeqCst); |
|
| 317 | + | let mut slot = self.conn.lock().await; |
|
| 318 | + | if let Some(c) = slot.take() { |
|
| 319 | + | self.term.fetch_add(1, std::sync::atomic::Ordering::SeqCst); |
|
| 320 | + | let _ = c.conn.close().await; |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | pub(super) async fn pid(&self) -> Option<i32> { |
|
| 325 | + | self.conn.lock().await.as_ref().map(|c| c.pid) |
|
| 326 | + | } |
|
| 327 | + | } |
|
| 328 | + | ||
| 329 | + | #[cfg(test)] |
|
| 330 | + | mod tests { |
|
| 331 | + | #[test] |
|
| 332 | + | fn keys_are_stable_and_per_schema() { |
|
| 333 | + | assert_eq!(super::key("hub_a", "flow.scheduler"), super::key("hub_a", "flow.scheduler")); |
|
| 334 | + | assert_ne!(super::key("hub_a", "flow.scheduler"), super::key("hub_b", "flow.scheduler")); |
|
| 335 | + | assert_ne!(super::key("hub_a", "flow.scheduler"), super::key("hub_a", "flow.reaper")); |
|
| 336 | + | } |
|
| 337 | + | } |
|
| 338 | + | } |
added crates/hub/tests/roles.rs +174 −0
| 1 | + | //! Roles (docs/roles.md, #272): a web-only hub starts none of the worker's |
|
| 2 | + | //! background tasks, a worker-only hub serves no pages, and on PostgreSQL |
|
| 3 | + | //! two hubs on one database run each singleton task once between them. |
|
| 4 | + | ||
| 5 | + | use axum::body::Body; |
|
| 6 | + | use axum::http::{header, Request, StatusCode}; |
|
| 7 | + | use http_body_util::BodyExt; |
|
| 8 | + | use oxygenhub::config::Config; |
|
| 9 | + | use oxygenhub::Shared; |
|
| 10 | + | use std::path::Path; |
|
| 11 | + | use std::time::{Duration, Instant}; |
|
| 12 | + | use tower::ServiceExt; |
|
| 13 | + | ||
| 14 | + | /// The worker's singleton tasks that run for as long as the process does. |
|
| 15 | + | const WORKER_SINGLETONS: [&str; 7] = |
|
| 16 | + | ["backups.scheduler", "diffusion.reconciler", "flow.placement", "flow.reaper", "flow.scheduler", "hooks.worker", "maintenance.nightly"]; |
|
| 17 | + | ||
| 18 | + | fn hermetic() { |
|
| 19 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 20 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 21 | + | ONCE.call_once(|| { |
|
| 22 | + | let d = tempfile::tempdir().unwrap(); |
|
| 23 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 24 | + | DIR.set(d).ok(); |
|
| 25 | + | }); |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | fn config(dir: &Path, roles: &[&str]) -> Config { |
|
| 29 | + | let mut config = Config::load(dir).unwrap(); |
|
| 30 | + | config.server.roles = roles.iter().map(|r| r.to_string()).collect(); |
|
| 31 | + | config.registration.setup_code = "test-setup".into(); |
|
| 32 | + | config |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | async fn get(app: &Shared, path: &str, token: Option<&str>) -> (StatusCode, String) { |
|
| 36 | + | let mut req = Request::get(path); |
|
| 37 | + | if let Some(t) = token { |
|
| 38 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); |
|
| 39 | + | } |
|
| 40 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::empty()).unwrap()).await.unwrap(); |
|
| 41 | + | let status = res.status(); |
|
| 42 | + | let body = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 43 | + | (status, String::from_utf8_lossy(&body).into_owned()) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | async fn health(app: &Shared) -> serde_json::Value { |
|
| 47 | + | let (status, body) = get(app, "/health", None).await; |
|
| 48 | + | assert_eq!(status, StatusCode::OK, "{body}"); |
|
| 49 | + | serde_json::from_str(&body).unwrap() |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// Wait up to `limit` for `ok`, checking every 100 ms. |
|
| 53 | + | async fn until(limit: Duration, mut ok: impl FnMut() -> bool) -> bool { |
|
| 54 | + | let start = Instant::now(); |
|
| 55 | + | while start.elapsed() < limit { |
|
| 56 | + | if ok() { |
|
| 57 | + | return true; |
|
| 58 | + | } |
|
| 59 | + | tokio::time::sleep(Duration::from_millis(100)).await; |
|
| 60 | + | } |
|
| 61 | + | ok() |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 65 | + | async fn a_web_only_hub_starts_none_of_the_workers_tasks() { |
|
| 66 | + | hermetic(); |
|
| 67 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 68 | + | let app = oxygenhub::build(config(dir.path(), &["web"])).await.unwrap(); |
|
| 69 | + | // Only the transparency log's checkpointer, which goes with the log's |
|
| 70 | + | // appends in the web role. |
|
| 71 | + | assert_eq!(app.leader.tasks(), ["tlog.checkpointer"]); |
|
| 72 | + | assert!(until(Duration::from_secs(5), || app.leader.leading() == ["tlog.checkpointer"]).await, "{:?}", app.leader.leading()); |
|
| 73 | + | let h = health(&app).await; |
|
| 74 | + | assert_eq!(h["roles"], serde_json::json!(["web"])); |
|
| 75 | + | assert_eq!(h["leading"], serde_json::json!(["tlog.checkpointer"])); |
|
| 76 | + | // It serves pages. |
|
| 77 | + | let (status, body) = get(&app, "/explore", None).await; |
|
| 78 | + | assert_eq!(status, StatusCode::OK, "{body}"); |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 82 | + | async fn a_worker_only_hub_serves_its_health_and_metrics_and_no_pages() { |
|
| 83 | + | hermetic(); |
|
| 84 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 85 | + | let mut cfg = config(dir.path(), &["worker"]); |
|
| 86 | + | cfg.server.metrics_token = "m".into(); |
|
| 87 | + | let app = oxygenhub::build(cfg).await.unwrap(); |
|
| 88 | + | let mut tasks = WORKER_SINGLETONS.to_vec(); |
|
| 89 | + | tasks.extend(["diffusion.backfill", "images.learn_archs"]); |
|
| 90 | + | tasks.sort(); |
|
| 91 | + | assert_eq!(app.leader.tasks(), tasks, "every worker task, and not the log's checkpointer"); |
|
| 92 | + | assert!( |
|
| 93 | + | until(Duration::from_secs(5), || WORKER_SINGLETONS.iter().all(|n| app.leader.leading().contains(n))).await, |
|
| 94 | + | "{:?}", |
|
| 95 | + | app.leader.leading() |
|
| 96 | + | ); |
|
| 97 | + | let h = health(&app).await; |
|
| 98 | + | assert_eq!(h["roles"], serde_json::json!(["worker"])); |
|
| 99 | + | assert!(h["leading"].as_array().unwrap().iter().any(|n| n == "flow.scheduler"), "{h}"); |
|
| 100 | + | for path in ["/", "/explore", "/login", "/api/v1/version", "/andy/demo", "/log/checkpoint"] { |
|
| 101 | + | assert_eq!(get(&app, path, None).await.0, StatusCode::NOT_FOUND, "{path} is the web role's"); |
|
| 102 | + | } |
|
| 103 | + | let (status, body) = get(&app, "/metrics", Some("m")).await; |
|
| 104 | + | assert_eq!(status, StatusCode::OK, "{body}"); |
|
| 105 | + | // It reads the transparency log but never writes it. |
|
| 106 | + | assert!(app.tlog.append(&[]).is_err()); |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 110 | + | async fn a_hub_takes_every_role_by_default() { |
|
| 111 | + | hermetic(); |
|
| 112 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 113 | + | let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap(); |
|
| 114 | + | assert_eq!(app.leader.tasks().len(), 10, "{:?}", app.leader.tasks()); |
|
| 115 | + | assert_eq!(health(&app).await["roles"], serde_json::json!(["web", "worker"])); |
|
| 116 | + | assert_eq!(get(&app, "/explore", None).await.0, StatusCode::OK); |
|
| 117 | + | let mut bad = config(dir.path(), &["web", "cron"]); |
|
| 118 | + | bad.server.roles.push(String::new()); |
|
| 119 | + | let e = oxygenhub::build(bad).await.err().expect("an unknown role is refused").to_string(); |
|
| 120 | + | assert!(e.contains("unknown role \"cron\""), "{e}"); |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// Two hubs on one PostgreSQL database (one schema): exactly one leads |
|
| 124 | + | /// each singleton; when its connection drops it stops them, and when it |
|
| 125 | + | /// stops the other takes every one over within seconds. |
|
| 126 | + | #[cfg(feature = "postgres")] |
|
| 127 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 128 | + | async fn two_hubs_on_one_database_run_each_singleton_once_and_fail_over() { |
|
| 129 | + | hermetic(); |
|
| 130 | + | let (da, dbb) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); |
|
| 131 | + | let schema = format!("hub_roles_{}", da.path().file_name().unwrap().to_string_lossy()); |
|
| 132 | + | let pg = |dir: &Path| { |
|
| 133 | + | let mut c = config(dir, &[]); |
|
| 134 | + | c.database.schema = schema.clone(); |
|
| 135 | + | c |
|
| 136 | + | }; |
|
| 137 | + | let a = oxygenhub::build(pg(da.path())).await.unwrap(); |
|
| 138 | + | let b = oxygenhub::build(pg(dbb.path())).await.unwrap(); |
|
| 139 | + | let mut singletons = WORKER_SINGLETONS.to_vec(); |
|
| 140 | + | singletons.push("tlog.checkpointer"); |
|
| 141 | + | let once = |a: &Shared, b: &Shared| { |
|
| 142 | + | let (la, lb) = (a.leader.leading(), b.leader.leading()); |
|
| 143 | + | singletons.iter().all(|n| la.contains(n) != lb.contains(n)) |
|
| 144 | + | }; |
|
| 145 | + | assert!(until(Duration::from_secs(10), || once(&a, &b)).await, "a {:?}, b {:?}", a.leader.leading(), b.leader.leading()); |
|
| 146 | + | ||
| 147 | + | // A's connection is ended, as when the network drops: its locks free, |
|
| 148 | + | // it stops what it led, and between them each runs once again. |
|
| 149 | + | let pid = a.leader.session_pid().await.expect("a has a session"); |
|
| 150 | + | let ended: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)").bind(pid).fetch_one(&b.db).await.unwrap(); |
|
| 151 | + | assert!(ended); |
|
| 152 | + | let start = Instant::now(); |
|
| 153 | + | loop { |
|
| 154 | + | let reconnected = a.leader.session_pid().await.is_some_and(|p| p != pid); |
|
| 155 | + | if reconnected && once(&a, &b) { |
|
| 156 | + | break; |
|
| 157 | + | } |
|
| 158 | + | assert!(start.elapsed() < Duration::from_secs(15), "a {:?}, b {:?}", a.leader.leading(), b.leader.leading()); |
|
| 159 | + | tokio::time::sleep(Duration::from_millis(100)).await; |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | // The hub leading the scheduler stops; the other takes everything over. |
|
| 163 | + | let (gone, left) = if a.leader.leading().contains(&"flow.scheduler") { (&a, &b) } else { (&b, &a) }; |
|
| 164 | + | gone.leader.resign().await; |
|
| 165 | + | let start = Instant::now(); |
|
| 166 | + | assert!( |
|
| 167 | + | until(Duration::from_secs(10), || singletons.iter().all(|n| left.leader.leading().contains(n))).await, |
|
| 168 | + | "left {:?}", |
|
| 169 | + | left.leader.leading() |
|
| 170 | + | ); |
|
| 171 | + | eprintln!("took over in {:.1} s", start.elapsed().as_secs_f64()); |
|
| 172 | + | assert!(gone.leader.leading().is_empty(), "{:?}", gone.leader.leading()); |
|
| 173 | + | assert_eq!(health(left).await["leading"].as_array().unwrap().len(), left.leader.leading().len()); |
|
| 174 | + | } |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **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. |
|
| 7 | 8 | - **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. |
|
| 8 | 9 | - **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. |
|
| 9 | 10 | - **Land when checks pass, or land now over them (#261):** anyone who may land a proposal can set it to land by itself, on the proposal, through `POST`/`DELETE …/proposals/{n}/auto-land` or with `oh proposal land N --when-ready` (`--off` to clear it). Once every required check has passed and nothing else blocks it, it lands as the person who set it, if they may still land. A required check that fails or is cancelled turns it off with a note on the timeline; a push to the source makes it wait again. When required checks are the only thing in the way (pending, or failed with the runners down), a maintainer may land now with a reason: on the proposal, with `{"over_checks": "reason"}` on the API's land, or `oh proposal land N --over-checks REASON`. Only the checks are passed over; approvals, code owners, requested changes, drafts, dependencies and the trust policy still apply. Both are audited, and the proposal, its timeline and the pushes page say how it landed. |
|
| … | 248 unchanged lines |
modified crates/hub/src/config.rs +119 −0
| … | 237 unchanged lines |
||
| 238 | 238 | /// A Grafana for this hub's metrics, linked from Insights (oxygenhub#257) |
|
| 239 | 239 | /// for charts of one's own; empty, no link. |
|
| 240 | 240 | pub grafana_url: String, |
|
| 241 | + | /// What this process does (docs/roles.md, #272): `web` (pages, the |
|
| 242 | + | /// API, the wire protocol, the runner API) and `worker` (the background |
|
| 243 | + | /// tasks). Empty, both. `serve --role` overrides it. |
|
| 244 | + | pub roles: Vec<String>, |
|
| 241 | 245 | } |
|
| 242 | 246 | ||
| 247 | + | /// One part of the hub's work a process can take (docs/roles.md, #272). |
|
| 248 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 249 | + | pub enum Role { |
|
| 250 | + | /// Pages, the JSON API, the wire protocol, live pages and the runner |
|
| 251 | + | /// API, and the transparency log they append to. |
|
| 252 | + | Web, |
|
| 253 | + | /// The background tasks: webhooks, Flow's scheduler, reaper and |
|
| 254 | + | /// placement pass, nightly upkeep, backups and Diffusion's index. |
|
| 255 | + | Worker, |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | impl Role { |
|
| 259 | + | pub const ALL: [Role; 2] = [Role::Web, Role::Worker]; |
|
| 260 | + | ||
| 261 | + | pub fn name(self) -> &'static str { |
|
| 262 | + | match self { |
|
| 263 | + | Role::Web => "web", |
|
| 264 | + | Role::Worker => "worker", |
|
| 265 | + | } |
|
| 266 | + | } |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | impl std::str::FromStr for Role { |
|
| 270 | + | type Err = String; |
|
| 271 | + | ||
| 272 | + | fn from_str(s: &str) -> Result<Role, String> { |
|
| 273 | + | match s.trim().to_ascii_lowercase().as_str() { |
|
| 274 | + | "web" => Ok(Role::Web), |
|
| 275 | + | "worker" => Ok(Role::Worker), |
|
| 276 | + | _ => Err(format!("unknown role {:?}: the roles are web and worker", s.trim())), |
|
| 277 | + | } |
|
| 278 | + | } |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | /// The roles one process takes: every role unless some are named. |
|
| 282 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 283 | + | pub struct Roles { |
|
| 284 | + | pub web: bool, |
|
| 285 | + | pub worker: bool, |
|
| 286 | + | } |
|
| 287 | + | ||
| 288 | + | impl Default for Roles { |
|
| 289 | + | fn default() -> Self { |
|
| 290 | + | Roles { web: true, worker: true } |
|
| 291 | + | } |
|
| 292 | + | } |
|
| 293 | + | ||
| 294 | + | impl Roles { |
|
| 295 | + | /// The roles named, each `web` or `worker`; none named is every role. |
|
| 296 | + | pub fn from_names<S: AsRef<str>>(names: &[S]) -> Result<Roles, String> { |
|
| 297 | + | let names: Vec<&str> = names.iter().map(|s| s.as_ref().trim()).filter(|s| !s.is_empty()).collect(); |
|
| 298 | + | if names.is_empty() { |
|
| 299 | + | return Ok(Roles::default()); |
|
| 300 | + | } |
|
| 301 | + | let mut roles = Roles { web: false, worker: false }; |
|
| 302 | + | for n in names { |
|
| 303 | + | match n.parse::<Role>()? { |
|
| 304 | + | Role::Web => roles.web = true, |
|
| 305 | + | Role::Worker => roles.worker = true, |
|
| 306 | + | } |
|
| 307 | + | } |
|
| 308 | + | Ok(roles) |
|
| 309 | + | } |
|
| 310 | + | ||
| 311 | + | pub fn has(self, role: Role) -> bool { |
|
| 312 | + | match role { |
|
| 313 | + | Role::Web => self.web, |
|
| 314 | + | Role::Worker => self.worker, |
|
| 315 | + | } |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | /// The roles' names, in a fixed order: what `/health` reports. |
|
| 319 | + | pub fn names(self) -> Vec<&'static str> { |
|
| 320 | + | Role::ALL.into_iter().filter(|r| self.has(*r)).map(Role::name).collect() |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | ||
| 243 | 324 | #[derive(Clone, Debug, Deserialize)] |
|
| 244 | 325 | #[serde(default, deny_unknown_fields)] |
|
| 245 | 326 | pub struct Tls { |
|
| … | 89 unchanged lines |
||
| 335 | 416 | hsts_max_age: 0, |
|
| 336 | 417 | metrics_token: String::new(), |
|
| 337 | 418 | grafana_url: String::new(), |
|
| 419 | + | roles: Vec::new(), |
|
| 338 | 420 | } |
|
| 339 | 421 | } |
|
| 340 | 422 | } |
|
| … | 50 unchanged lines |
||
| 391 | 473 | }; |
|
| 392 | 474 | cfg.data = data.to_path_buf(); |
|
| 393 | 475 | cfg.apply_env(|k| std::env::var(k).ok()); |
|
| 476 | + | cfg.roles()?; |
|
| 394 | 477 | Ok(cfg) |
|
| 395 | 478 | } |
|
| 396 | 479 | ||
| … | 38 unchanged lines |
||
| 435 | 518 | if let Some(v) = get("OXYHUB_SERVER_GRAFANA_URL") { |
|
| 436 | 519 | self.server.grafana_url = v; |
|
| 437 | 520 | } |
|
| 521 | + | if let Some(v) = get("OXYHUB_SERVER_ROLES") { |
|
| 522 | + | self.server.roles = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); |
|
| 523 | + | } |
|
| 438 | 524 | if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") { |
|
| 439 | 525 | self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes"); |
|
| 440 | 526 | } |
|
| … | 9 unchanged lines |
||
| 450 | 536 | if let Some(v) = get("OXYHUB_LIMITS_RENDER_LIMIT").and_then(|v| ByteSize::parse(&v)) { |
|
| 451 | 537 | self.limits.render_limit = v; |
|
| 452 | 538 | } |
|
| 539 | + | } |
|
| 540 | + | ||
| 541 | + | /// This process's roles (`[server] roles`); an unknown name is an error. |
|
| 542 | + | pub fn roles(&self) -> anyhow::Result<Roles> { |
|
| 543 | + | Roles::from_names(&self.server.roles).map_err(|e| anyhow::anyhow!("[server] roles: {e}")) |
|
| 453 | 544 | } |
|
| 454 | 545 | ||
| 455 | 546 | pub fn bind_addr(&self) -> anyhow::Result<Option<SocketAddr>> { |
|
| … | 67 unchanged lines |
||
| 523 | 614 | assert_eq!(cfg.auth.push_tokens, ["a", "b"]); |
|
| 524 | 615 | assert_eq!((cfg.server.metrics_token.as_str(), cfg.server.grafana_url.as_str()), ("m", "https://grafana.example")); |
|
| 525 | 616 | assert!(Config::parse("[nope]\nx = 1\n").is_err(), "unknown sections are errors, not silence"); |
|
| 617 | + | } |
|
| 618 | + | ||
| 619 | + | #[test] |
|
| 620 | + | fn roles_default_to_all_and_unknown_names_are_errors() { |
|
| 621 | + | let cfg = Config::default(); |
|
| 622 | + | assert_eq!(cfg.roles().unwrap(), Roles { web: true, worker: true }); |
|
| 623 | + | assert_eq!(cfg.roles().unwrap().names(), ["web", "worker"]); |
|
| 624 | + | assert_eq!(Roles::from_names(&["worker"]).unwrap(), Roles { web: false, worker: true }); |
|
| 625 | + | assert_eq!(Roles::from_names(&[" Web ", "web"]).unwrap().names(), ["web"]); |
|
| 626 | + | assert_eq!(Roles::from_names(&["worker", "web"]).unwrap().names(), ["web", "worker"]); |
|
| 627 | + | assert_eq!(Roles::from_names::<&str>(&[]).unwrap(), Roles::default()); |
|
| 628 | + | let e = Roles::from_names(&["web", "edge"]).unwrap_err(); |
|
| 629 | + | assert_eq!(e, "unknown role \"edge\": the roles are web and worker"); |
|
| 630 | + | ||
| 631 | + | let mut cfg = Config::parse("[server]\nroles = [\"worker\"]\n").unwrap(); |
|
| 632 | + | assert_eq!(cfg.roles().unwrap().names(), ["worker"]); |
|
| 633 | + | cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "web, worker".into())); |
|
| 634 | + | assert_eq!(cfg.roles().unwrap(), Roles::default()); |
|
| 635 | + | cfg.apply_env(|k| (k == "OXYHUB_SERVER_ROLES").then(|| "store".into())); |
|
| 636 | + | assert!(cfg.roles().unwrap_err().to_string().contains("unknown role \"store\"")); |
|
| 637 | + | } |
|
| 638 | + | ||
| 639 | + | #[test] |
|
| 640 | + | fn an_unknown_role_in_hub_toml_stops_loading() { |
|
| 641 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 642 | + | std::fs::write(dir.path().join("hub.toml"), "[server]\nroles = [\"wrker\"]\n").unwrap(); |
|
| 643 | + | let e = Config::load(dir.path()).unwrap_err().to_string(); |
|
| 644 | + | assert!(e.contains("unknown role \"wrker\""), "{e}"); |
|
| 526 | 645 | } |
|
| 527 | 646 | } |
modified crates/hub/src/lib.rs +70 −22
| 1 | 1 | //! OxygenHub: a forge for Oxygen repositories (`docs/spec.md`). |
|
| 2 | 2 | //! |
|
| 3 | - | //! One process: an axum router serving pages, the JSON API and the wire |
|
| 4 | - | //! protocol; SQLite for hub state; repositories as bare Oxygen stores. |
|
| 3 | + | //! One process, or several sharing a database: an axum router serving |
|
| 4 | + | //! pages, the JSON API and the wire protocol (the web role); the background |
|
| 5 | + | //! tasks (the worker role, singletons under a leader lock: docs/roles.md); |
|
| 6 | + | //! SQLite or PostgreSQL for hub state; repositories as bare Oxygen stores. |
|
| 5 | 7 | ||
| 6 | 8 | pub mod api; |
|
| 7 | 9 | pub mod apt; |
|
| … | 13 unchanged lines |
||
| 21 | 23 | pub mod junit; |
|
| 22 | 24 | pub mod keyless; |
|
| 23 | 25 | pub mod keys; |
|
| 26 | + | pub mod leader; |
|
| 24 | 27 | pub mod levels; |
|
| 25 | 28 | pub mod live; |
|
| 26 | 29 | pub mod mail; |
|
| … | 57 unchanged lines |
||
| 84 | 87 | pub started: std::time::Instant, |
|
| 85 | 88 | /// The transparency log (#238). |
|
| 86 | 89 | pub tlog: Arc<tlog::Log>, |
|
| 90 | + | /// What this process does: web, worker or both (docs/roles.md, #272). |
|
| 91 | + | pub roles: config::Roles, |
|
| 92 | + | /// The background tasks started here and the leader locks held (#272). |
|
| 93 | + | pub leader: leader::Leader, |
|
| 87 | 94 | } |
|
| 88 | 95 | ||
| 89 | 96 | impl App { |
|
| … | 14 unchanged lines |
||
| 104 | 111 | /// Open the data directory and build the application state. `init` must |
|
| 105 | 112 | /// have run once; `serve` and the tests call this. |
|
| 106 | 113 | pub async fn build(config: Config) -> anyhow::Result<Shared> { |
|
| 114 | + | let roles = config.roles()?; |
|
| 107 | 115 | std::fs::create_dir_all(config.repos_dir())?; |
|
| 108 | 116 | let db = db::open(&config).await?; |
|
| 109 | 117 | let repos = Arc::new(Repos::new(config.repos_dir())); |
|
| … | 13 unchanged lines |
||
| 123 | 131 | let slot = backend.app_slot(); |
|
| 124 | 132 | let server = oxy_server::Server::new(backend); |
|
| 125 | 133 | let mail = mail::load(&db, &config.email).await; |
|
| 126 | - | let tlog = Arc::new(tlog::Log::open(&config.data.join("tlog"), &tlog::origin_of(config.base_url()))?); |
|
| 134 | + | // The web role appends to the log; a worker alone only reads it (#272). |
|
| 135 | + | let (tlog_dir, origin) = (config.data.join("tlog"), tlog::origin_of(config.base_url())); |
|
| 136 | + | let tlog = Arc::new(if roles.web { tlog::Log::open(&tlog_dir, &origin)? } else { tlog::Log::open_reader(&tlog_dir, &origin)? }); |
|
| 127 | 137 | tlog.configure(&config.tlog)?; |
|
| 128 | 138 | let app = Arc::new(App { |
|
| 129 | 139 | mail: std::sync::RwLock::new(mail), |
|
| … | 14 unchanged lines |
||
| 144 | 154 | swarm: Default::default(), |
|
| 145 | 155 | started: std::time::Instant::now(), |
|
| 146 | 156 | tlog, |
|
| 157 | + | roles, |
|
| 158 | + | leader: leader::Leader::default(), |
|
| 147 | 159 | }); |
|
| 148 | 160 | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| 149 | 161 | storage::load(&app).await?; |
|
| 150 | 162 | coverage::load(&app).await?; |
|
| 151 | 163 | junit::load(&app).await?; |
|
| 152 | 164 | reports::load(&app).await?; |
|
| 153 | - | tokio::spawn(hooks::worker(app.clone())); |
|
| 154 | - | tokio::spawn(tlog::checkpointer(app.clone())); |
|
| 155 | - | tokio::spawn(flow::reaper(app.clone())); |
|
| 156 | - | tokio::spawn(flow::scheduler(app.clone())); |
|
| 157 | - | tokio::spawn(flow::placement(app.clone())); |
|
| 158 | - | tokio::spawn(maintenance::nightly(app.clone())); |
|
| 159 | - | tokio::spawn(backups::scheduler(app.clone())); |
|
| 160 | - | // The reverse pin index for repositories pushed before it existed (#148). |
|
| 161 | - | tokio::spawn({ |
|
| 162 | - | let app = app.clone(); |
|
| 163 | - | async move { diffusion::backfill(&app).await } |
|
| 164 | - | }); |
|
| 165 | + | start_background(&app); |
|
| 166 | + | setup::ensure(&app).await?; |
|
| 167 | + | Ok(app) |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | /// Start this process's background tasks (docs/roles.md, #272). The |
|
| 171 | + | /// worker role's are singletons across every process sharing the database, |
|
| 172 | + | /// each run under its leader lock; the transparency log's checkpointer goes |
|
| 173 | + | /// with the web role, which appends to the log, and is a singleton too. |
|
| 174 | + | fn start_background(app: &Shared) { |
|
| 175 | + | if app.roles.web { |
|
| 176 | + | // The checkpointer signs the log this process appends to (#238). |
|
| 177 | + | leader::run(app, "tlog.checkpointer", tlog::checkpointer); |
|
| 178 | + | } |
|
| 179 | + | if !app.roles.worker { |
|
| 180 | + | return; |
|
| 181 | + | } |
|
| 182 | + | leader::run(app, "hooks.worker", hooks::worker); |
|
| 183 | + | leader::run(app, "flow.reaper", flow::reaper); |
|
| 184 | + | leader::run(app, "flow.scheduler", flow::scheduler); |
|
| 185 | + | leader::run(app, "flow.placement", flow::placement); |
|
| 186 | + | leader::run(app, "maintenance.nightly", maintenance::nightly); |
|
| 187 | + | leader::run(app, "backups.scheduler", backups::scheduler); |
|
| 188 | + | // The reverse pin index for repositories pushed before it existed (#148): |
|
| 189 | + | // once, by whichever worker leads it first (idempotent if run again). |
|
| 190 | + | leader::run(app, "diffusion.backfill", |app| async move { diffusion::backfill(&app).await }); |
|
| 165 | 191 | // Mend the pin index and the bumps it drives (oxygenhub#210). |
|
| 166 | - | tokio::spawn(diffusion::reconciler(app.clone())); |
|
| 167 | - | // What each image is for, read or learned once (oxygenhub#173). |
|
| 192 | + | leader::run(app, "diffusion.reconciler", diffusion::reconciler); |
|
| 193 | + | // What each image is for, read or learned once (oxygenhub#173): safe |
|
| 194 | + | // in every worker, so it takes no lock. |
|
| 195 | + | app.leader.note("images.learn_archs"); |
|
| 168 | 196 | tokio::spawn({ |
|
| 169 | 197 | let app = app.clone(); |
|
| 170 | 198 | async move { |
|
| … | 4 unchanged lines |
||
| 175 | 203 | } |
|
| 176 | 204 | } |
|
| 177 | 205 | }); |
|
| 178 | - | setup::ensure(&app).await?; |
|
| 179 | - | Ok(app) |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | /// The router this process serves: everything for the web role; for a |
|
| 209 | + | /// worker alone, `/health` and `/metrics`, so it can be watched. |
|
| 210 | + | pub fn router(app: Shared) -> axum::Router { |
|
| 211 | + | if !app.roles.web { |
|
| 212 | + | return worker_router(app); |
|
| 213 | + | } |
|
| 214 | + | full_router(app) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | /// What a worker-only process answers: its health and its metrics. |
|
| 218 | + | fn worker_router(app: Shared) -> axum::Router { |
|
| 219 | + | use axum::routing::get; |
|
| 220 | + | let hsts = headers::hsts(app.config.base_url(), app.config.server.hsts_max_age); |
|
| 221 | + | axum::Router::new() |
|
| 222 | + | .route("/health", get(web::home::health)) |
|
| 223 | + | .route("/metrics", get(metrics::metrics)) |
|
| 224 | + | .with_state(app) |
|
| 225 | + | .layer(axum::middleware::from_fn_with_state(hsts, headers::security_headers)) |
|
| 180 | 226 | } |
|
| 181 | 227 | ||
| 182 | 228 | /// The full router: pages, API, protocol and static assets. |
|
| 183 | - | pub fn router(app: Shared) -> axum::Router { |
|
| 229 | + | fn full_router(app: Shared) -> axum::Router { |
|
| 184 | 230 | let https = app_https(&app); |
|
| 185 | 231 | let hsts = headers::hsts(app.config.base_url(), app.config.server.hsts_max_age); |
|
| 186 | 232 | web::router(app.clone()) |
|
| … | 106 unchanged lines |
||
| 293 | 339 | let router = router(app.clone()); |
|
| 294 | 340 | let http = app.config.bind_addr()?; |
|
| 295 | 341 | let https = app.config.tls_bind_addr()?; |
|
| 296 | - | if http.is_none() && https.is_none() { |
|
| 342 | + | if http.is_none() && https.is_none() && app.roles.web { |
|
| 297 | 343 | anyhow::bail!("nothing to bind: set server.bind or server.tls_bind"); |
|
| 298 | 344 | } |
|
| 345 | + | tracing::info!("roles: {}", app.roles.names().join(", ")); |
|
| 299 | 346 | // Installed first: from here a SIGTERM drains rather than kills. |
|
| 300 | 347 | let signal = shutdown::on_signal()?; |
|
| 301 | 348 | let mut tasks = tokio::task::JoinSet::new(); |
|
| … | 38 unchanged lines |
||
| 340 | 387 | }); |
|
| 341 | 388 | } |
|
| 342 | 389 | tokio::select! { |
|
| 343 | - | r = tasks.join_next() => { r.transpose()?.transpose()?; Ok(()) } |
|
| 390 | + | // A worker with no address to serve on runs until it is stopped. |
|
| 391 | + | r = tasks.join_next(), if !tasks.is_empty() => { r.transpose()?.transpose()?; Ok(()) } |
|
| 344 | 392 | _ = signal => { |
|
| 345 | 393 | // The servers stop taking connections and finish what they |
|
| 346 | 394 | // have; whatever is still running after DRAIN is cut off. |
|
| … | 9 unchanged lines |
modified crates/hub/src/main.rs +13 −3
| … | 28 unchanged lines |
||
| 29 | 29 | self_signed: bool, |
|
| 30 | 30 | }, |
|
| 31 | 31 | /// Run the hub |
|
| 32 | - | Serve, |
|
| 32 | + | Serve { |
|
| 33 | + | /// What this process does: web (pages, API, wire protocol, runner |
|
| 34 | + | /// API), worker (the background tasks), or both, comma-separated. |
|
| 35 | + | /// Overrides [server] roles; every role when neither is given |
|
| 36 | + | /// (docs/roles.md) |
|
| 37 | + | #[arg(long = "role", value_name = "ROLE", value_delimiter = ',')] |
|
| 38 | + | roles: Vec<oxygenhub::config::Role>, |
|
| 39 | + | }, |
|
| 33 | 40 | /// Offline administration |
|
| 34 | 41 | Admin { |
|
| 35 | 42 | #[command(subcommand)] |
|
| … | 129 unchanged lines |
||
| 165 | 172 | let cli = Cli::parse(); |
|
| 166 | 173 | match cli.cmd { |
|
| 167 | 174 | Cmd::Init { base_url, self_signed } => init(&cli.data, base_url, self_signed).await, |
|
| 168 | - | Cmd::Serve => { |
|
| 169 | - | let config = Config::load(&cli.data)?; |
|
| 175 | + | Cmd::Serve { roles } => { |
|
| 176 | + | let mut config = Config::load(&cli.data)?; |
|
| 177 | + | if !roles.is_empty() { |
|
| 178 | + | config.server.roles = roles.iter().map(|r| r.name().to_string()).collect(); |
|
| 179 | + | } |
|
| 170 | 180 | let app = oxygenhub::build(config).await?; |
|
| 171 | 181 | oxygenhub::serve(app).await |
|
| 172 | 182 | } |
|
| … | 211 unchanged lines |
modified crates/hub/src/tlog.rs +64 −8
| … | 70 unchanged lines |
||
| 71 | 71 | witnesses: std::sync::RwLock<Vec<Peer>>, |
|
| 72 | 72 | /// Logs this hub witnesses: their origins and keys. |
|
| 73 | 73 | witness_for: std::sync::RwLock<Vec<(String, Identity)>>, |
|
| 74 | + | /// Whether this process writes the log: the web role's (#272). A |
|
| 75 | + | /// process without it only reads what the log held when it opened. |
|
| 76 | + | writer: bool, |
|
| 74 | 77 | } |
|
| 75 | 78 | ||
| 76 | 79 | /// A witness of this hub's log. |
|
| … | 21 unchanged lines |
||
| 98 | 101 | /// Open (or start) the log in `dir`, recovering from an interrupted |
|
| 99 | 102 | /// append. |
|
| 100 | 103 | pub fn open(dir: &Path, origin: &str) -> anyhow::Result<Log> { |
|
| 104 | + | Self::open_as(dir, origin, true) |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | /// Open the log to read only, as a process without the web role does |
|
| 108 | + | /// (#272): it neither appends nor signs, and leaves the files as they |
|
| 109 | + | /// are, so it never cuts short an append the writer is making. Until |
|
| 110 | + | /// the log has a cluster-wide writer (#274), one process per data |
|
| 111 | + | /// directory has the web role. |
|
| 112 | + | pub fn open_reader(dir: &Path, origin: &str) -> anyhow::Result<Log> { |
|
| 113 | + | Self::open_as(dir, origin, false) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | fn open_as(dir: &Path, origin: &str, writer: bool) -> anyhow::Result<Log> { |
|
| 101 | 117 | std::fs::create_dir_all(dir)?; |
|
| 102 | 118 | let open = |name: &str| OpenOptions::new().read(true).append(true).create(true).open(dir.join(name)); |
|
| 103 | 119 | let (entries, offsets, hashes) = (open("entries")?, open("offsets")?, open("hashes")?); |
|
| … | 14 unchanged lines |
||
| 118 | 134 | } |
|
| 119 | 135 | size -= 1; |
|
| 120 | 136 | } |
|
| 121 | - | offsets.set_len(size * 8)?; |
|
| 122 | - | entries.set_len(entries_len)?; |
|
| 123 | 137 | // The hashes of every leaf whose hashes were all written; the rest |
|
| 124 | 138 | // are computed again from the entries. |
|
| 125 | 139 | let have = hashes.metadata()?.len() / 32; |
|
| 126 | 140 | let mut whole = size; |
|
| 127 | 141 | while tlog::stored_hash_count(whole) > have { |
|
| 128 | 142 | whole -= 1; |
|
| 143 | + | } |
|
| 144 | + | if !writer { |
|
| 145 | + | // A reader takes the leaves whose hashes are all there and |
|
| 146 | + | // changes nothing. |
|
| 147 | + | let files = Files { entries, offsets, hashes, size: whole, entries_len }; |
|
| 148 | + | return Ok(Log { |
|
| 149 | + | dir: dir.to_path_buf(), |
|
| 150 | + | origin: origin.to_string(), |
|
| 151 | + | files: Mutex::new(files), |
|
| 152 | + | checkpoint: Mutex::new(Self::read_checkpoint(dir, whole)), |
|
| 153 | + | witnesses: Default::default(), |
|
| 154 | + | witness_for: Default::default(), |
|
| 155 | + | writer, |
|
| 156 | + | }); |
|
| 129 | 157 | } |
|
| 158 | + | offsets.set_len(size * 8)?; |
|
| 159 | + | entries.set_len(entries_len)?; |
|
| 130 | 160 | hashes.set_len(tlog::stored_hash_count(whole) * 32)?; |
|
| 131 | 161 | let mut files = Files { entries, offsets, hashes, size: whole, entries_len }; |
|
| 132 | 162 | for n in whole..size { |
|
| … | 6 unchanged lines |
||
| 139 | 169 | } |
|
| 140 | 170 | files.size = size; |
|
| 141 | 171 | files.hashes.sync_data()?; |
|
| 142 | - | let checkpoint = std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| { |
|
| 143 | - | let n = tlog::Note::parse(¬e).ok()?; |
|
| 144 | - | let c = Checkpoint::parse(&n.text).ok()?; |
|
| 145 | - | (c.size <= size).then_some((c.size, note)) |
|
| 146 | - | }); |
|
| 147 | 172 | Ok(Log { |
|
| 148 | 173 | dir: dir.to_path_buf(), |
|
| 149 | 174 | origin: origin.to_string(), |
|
| 150 | 175 | files: Mutex::new(files), |
|
| 151 | - | checkpoint: Mutex::new(checkpoint), |
|
| 176 | + | checkpoint: Mutex::new(Self::read_checkpoint(dir, size)), |
|
| 152 | 177 | witnesses: Default::default(), |
|
| 153 | 178 | witness_for: Default::default(), |
|
| 179 | + | writer, |
|
| 154 | 180 | }) |
|
| 155 | 181 | } |
|
| 156 | 182 | ||
| 183 | + | /// The signed checkpoint kept in `dir`, if it is of at most `size` entries. |
|
| 184 | + | fn read_checkpoint(dir: &Path, size: u64) -> Option<(u64, String)> { |
|
| 185 | + | std::fs::read_to_string(dir.join("checkpoint")).ok().and_then(|note| { |
|
| 186 | + | let n = tlog::Note::parse(¬e).ok()?; |
|
| 187 | + | let c = Checkpoint::parse(&n.text).ok()?; |
|
| 188 | + | (c.size <= size).then_some((c.size, note)) |
|
| 189 | + | }) |
|
| 190 | + | } |
|
| 191 | + | ||
| 157 | 192 | fn entry_bytes(f: &Files, n: u64) -> anyhow::Result<Vec<u8>> { |
|
| 158 | 193 | let off = read_u64_at(&f.offsets, n * 8)?; |
|
| 159 | 194 | let mut len = [0u8; 2]; |
|
| … | 17 unchanged lines |
||
| 177 | 212 | ||
| 178 | 213 | /// Append entries, durably; their numbers. |
|
| 179 | 214 | pub fn append(&self, entries: &[Entry]) -> anyhow::Result<Vec<u64>> { |
|
| 215 | + | if !self.writer { |
|
| 216 | + | anyhow::bail!("this process does not write the log: it has no web role"); |
|
| 217 | + | } |
|
| 180 | 218 | let mut f = self.files.lock().unwrap(); |
|
| 181 | 219 | let mut out = Vec::with_capacity(entries.len()); |
|
| 182 | 220 | for e in entries { |
|
| … | 127 unchanged lines |
||
| 310 | 348 | /// Sign a checkpoint of the log as it is, unless the last one already |
|
| 311 | 349 | /// covers it. True when a new one was signed. |
|
| 312 | 350 | pub fn refresh_checkpoint(&self, identity: &oxy_core::identity::Identity, sk: &oxy_core::identity::SecretKey) -> anyhow::Result<bool> { |
|
| 351 | + | if !self.writer { |
|
| 352 | + | return Ok(false); |
|
| 353 | + | } |
|
| 313 | 354 | let size = self.size(); |
|
| 314 | 355 | if self.checkpoint.lock().unwrap().as_ref().is_some_and(|(s, _)| *s >= size) { |
|
| 315 | 356 | return Ok(false); |
|
| … | 360 unchanged lines |
||
| 676 | 717 | #[cfg(test)] |
|
| 677 | 718 | mod tests { |
|
| 678 | 719 | use super::*; |
|
| 720 | + | ||
| 721 | + | #[test] |
|
| 722 | + | fn a_reader_leaves_the_files_alone_and_cannot_append() { |
|
| 723 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 724 | + | let log = Log::open(dir.path(), "hub.example/log").unwrap(); |
|
| 725 | + | log.append(&[entry(0), entry(1)]).unwrap(); |
|
| 726 | + | // A torn append: an offset whose entry never arrived. |
|
| 727 | + | std::fs::OpenOptions::new().append(true).open(dir.path().join("offsets")).unwrap().write_all(&9999u64.to_le_bytes()).unwrap(); |
|
| 728 | + | let before = std::fs::metadata(dir.path().join("offsets")).unwrap().len(); |
|
| 729 | + | let reader = Log::open_reader(dir.path(), "hub.example/log").unwrap(); |
|
| 730 | + | assert_eq!(reader.size(), 2); |
|
| 731 | + | assert_eq!(std::fs::metadata(dir.path().join("offsets")).unwrap().len(), before, "the writer's files are untouched"); |
|
| 732 | + | assert!(reader.append(&[entry(2)]).is_err()); |
|
| 733 | + | assert_eq!(reader.tree_hash(2).unwrap(), log.tree_hash(2).unwrap()); |
|
| 734 | + | } |
|
| 679 | 735 | ||
| 680 | 736 | fn entry(i: u64) -> Entry { |
|
| 681 | 737 | Entry { kind: "ref".into(), repo: [1; 16], object: ObjectRef([i as u8; 32]), signers: vec![], received: i } |
|
| … | 60 unchanged lines |
modified crates/hub/src/web/home.rs +3 −0
| … | 414 unchanged lines |
||
| 415 | 415 | "version": env!("CARGO_PKG_VERSION"), |
|
| 416 | 416 | "build": env!("OXY_BUILD_ID"), |
|
| 417 | 417 | "uptime_s": app.started.elapsed().as_secs(), |
|
| 418 | + | // What this process does, and the singleton tasks it leads (#272). |
|
| 419 | + | "roles": app.roles.names(), |
|
| 420 | + | "leading": app.leader.leading(), |
|
| 418 | 421 | // What the state cache holds, beside the process's size (#117). |
|
| 419 | 422 | "state_cache": { |
|
| 420 | 423 | "states": states, |
|
| … | 74 unchanged lines |
modified docs/roles.md +59 −14
| 1 | 1 | # Roles: one binary, several processes |
|
| 2 | 2 | ||
| 3 | - | Status: design, 2026-10-19 (overnight loop 17). First slices: `--role` |
|
| 4 | - | with a leader lock (H2) and the event bus (H3). |
|
| 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. |
|
| 5 | 5 | ||
| 6 | 6 | OxygenHub is one process today: pages, API, the `oxy` wire protocol, Flow's |
|
| 7 | 7 | scheduler and every background task share one runtime and one data |
|
| … | 41 unchanged lines |
||
| 49 | 49 | | Task | What it does | Role | Singleton? | Needs for several processes | |
|
| 50 | 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 | - | | `tlog::checkpointer` | signs checkpoints, gathers witness cosignatures | worker | **yes** | the log's single writer (below) | |
|
| 52 | + | | `tlog::checkpointer` | signs checkpoints, gathers witness cosignatures | web, beside the log's appends, until #274 | **yes** | the log's single writer (below) | |
|
| 53 | 53 | | `flow::reaper` | ends jobs whose runner stopped reporting | worker | yes | leader lock | |
|
| 54 | 54 | | `flow::scheduler` | starts `schedule` runs on their cron lines | worker | **yes** (two would start each run twice) | leader lock | |
|
| 55 | 55 | | `flow::placement` | the placement pass: why each job waits | worker | yes | leader lock | |
|
| … | 29 unchanged lines |
||
| 85 | 85 | ||
| 86 | 86 | ## Leader lock |
|
| 87 | 87 | ||
| 88 | - | `Leader::run(name, task)`: a worker runs a singleton task only while it |
|
| 89 | - | holds a lock named for it. On Postgres, `pg_try_advisory_lock(hash(name))` |
|
| 90 | - | on a dedicated connection, retried every few seconds; if the holder dies, |
|
| 91 | - | its connection closes and the lock frees itself, so another worker takes |
|
| 92 | - | over within seconds. On SQLite (one machine by definition) the lock is |
|
| 93 | - | always held. Each task is written to be safe to run again after a |
|
| 94 | - | failover (they already are: they read their work from the database). |
|
| 88 | + | `leader::run(app, name, task)` (`crates/hub/src/leader.rs`): a process |
|
| 89 | + | runs a singleton task only while it holds a lock named for it. On |
|
| 90 | + | Postgres, `pg_try_advisory_lock(key)`, the key a stable 64-bit hash |
|
| 91 | + | (BLAKE3) of the hub's schema and the task's name, so two hubs in two |
|
| 92 | + | schemas of one database never share a lock. Every lock of one process is |
|
| 93 | + | held on one connection of its own, outside the pool, for the process's |
|
| 94 | + | life. A process that does not hold a lock asks again every 2 seconds. One |
|
| 95 | + | that holds it checks the connection every 2 seconds (one `SELECT 1` |
|
| 96 | + | answers for every task); if it has gone, the server has freed its locks, |
|
| 97 | + | so each task it led is stopped (its future dropped) and the process |
|
| 98 | + | contends again on a new connection. A process that dies closes its |
|
| 99 | + | connection and frees its locks; another takes over within seconds (2 s in |
|
| 100 | + | the test). A holder whose connection drops can overlap its successor for |
|
| 101 | + | at most those 2 seconds, which the tasks tolerate. On SQLite (one machine |
|
| 102 | + | by definition) the lock is always held. Each task is safe to run again |
|
| 103 | + | after a failover: they read their work from the database. A task that |
|
| 104 | + | ends by itself (`diffusion.backfill`) gives its lock back; the next |
|
| 105 | + | worker to take it runs it again, harmlessly. |
|
| 95 | 106 | ||
| 107 | + | The locks: `hooks.worker`, `flow.reaper`, `flow.scheduler`, |
|
| 108 | + | `flow.placement`, `maintenance.nightly`, `backups.scheduler`, |
|
| 109 | + | `diffusion.backfill`, `diffusion.reconciler` (worker role) and |
|
| 110 | + | `tlog.checkpointer` (web role, below). `images.learn_archs` runs in every |
|
| 111 | + | worker, without a lock. |
|
| 112 | + | ||
| 113 | + | `/health` says what the process does and which locks it holds: |
|
| 114 | + | `"roles": ["web", "worker"], "leading": ["backups.scheduler", …]`. |
|
| 115 | + | ||
| 116 | + | ## Running the roles apart (H2) |
|
| 117 | + | ||
| 118 | + | `oxygenhub serve --role web,worker` (or `[server] roles` in `hub.toml`, |
|
| 119 | + | `OXYHUB_SERVER_ROLES`); every role when none is given. An unknown role is |
|
| 120 | + | an error. |
|
| 121 | + | ||
| 122 | + | - **web** serves the whole router. It also owns the transparency log: it |
|
| 123 | + | appends to it (`after_push`, kept receipts and approvals) and runs its |
|
| 124 | + | checkpointer, under the `tlog.checkpointer` lock. The checkpointer moved |
|
| 125 | + | from the worker's list above because the log's state is in the process |
|
| 126 | + | that appends: a checkpointer elsewhere would sign a log it never sees |
|
| 127 | + | grow, and the web process would have no checkpoint to serve. |
|
| 128 | + | - **worker** starts the background tasks, and serves only `/health` and |
|
| 129 | + | `/metrics` (when `metrics_token` is set) on its bind address, so it can |
|
| 130 | + | be watched; with no bind address it serves nothing. It opens the |
|
| 131 | + | transparency log to read only: it never cuts short an append the web |
|
| 132 | + | process is making. |
|
| 133 | + | - **Until the log has a cluster-wide writer (#274), only one process per |
|
| 134 | + | 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. |
|
| 138 | + | ||
| 96 | 139 | ## Event bus: publish and subscribe |
|
| 97 | 140 | ||
| 98 | 141 | A trait with two operations, `publish(topic, payload)` and |
|
| … | 33 unchanged lines |
||
| 132 | 175 | ## Order of work |
|
| 133 | 176 | ||
| 134 | 177 | 1. **H2: `--role web,worker`** and the leader lock (Postgres advisory |
|
| 135 | - | locks; always held on SQLite). Two hubs on one Postgres and one data |
|
| 136 | - | directory: pushes through either, the scheduler and the placement pass |
|
| 137 | - | run on exactly one, failover when it stops. |
|
| 178 | + | locks; always held on SQLite). **Built (#272):** two hubs on one |
|
| 179 | + | Postgres run each singleton on exactly one, and the other takes over |
|
| 180 | + | within seconds when it stops or loses its connection. One web process |
|
| 181 | + | per data directory until step 3. |
|
| 138 | 182 | 2. **H3: the event bus trait**, in process and Postgres `LISTEN/NOTIFY`; |
|
| 139 | 183 | live pages on one node see a push made through the other. |
|
| 140 | - | 3. The transparency log's appends under a cluster-wide lock. |
|
| 184 | + | 3. The transparency log's appends under a cluster-wide lock (#274), and |
|
| 185 | + | its checkpointer back with the workers. |
|
| 141 | 186 | 4. Autoland and Diffusion under per-proposal advisory locks. |
|
| 142 | 187 | 5. The job queue, webhooks and mail first. |
|
| 143 | 188 | 6. Later: edge and store (the repository service), NATS when measured. |
modified docs/spec.md +6 −1
| … | 748 unchanged lines |
||
| 749 | 749 | tls_bind = "0.0.0.0:7343" # HTTPS, when tls.cert and tls.key (or self_signed) are set |
|
| 750 | 750 | base_url = "https://earth.andymccall.xyz:7343" |
|
| 751 | 751 | behind_proxy = false # trust X-Forwarded-* when true |
|
| 752 | + | roles = ["web", "worker"] # what this process does (docs/roles.md); empty or absent: both. |
|
| 753 | + | # `serve --role` overrides it. web: pages, API, wire protocol, |
|
| 754 | + | # runner API, the transparency log; worker: background tasks, |
|
| 755 | + | # singletons under a leader lock, serving only /health and /metrics. |
|
| 756 | + | # One web process per data directory until #274. |
|
| 752 | 757 | ||
| 753 | 758 | [tls] |
|
| 754 | 759 | self_signed = true # developer edition; otherwise cert = "...", key = "..." |
|
| … | 26 unchanged lines |
||
| 781 | 786 | ||
| 782 | 787 | ``` |
|
| 783 | 788 | oxygenhub init --data DIR [--admin NAME] create the data directory, keys, database, first admin |
|
| 784 | - | oxygenhub serve --data DIR run |
|
| 789 | + | oxygenhub serve --data DIR [--role web,worker] run, taking these roles (all by default) |
|
| 785 | 790 | oxygenhub admin --data DIR user|repo|token … offline administration |
|
| 786 | 791 | oxygenhub backup --data DIR --to FILE consistent snapshot: sqlite backup API plus repos and assets as tar.zst |
|
| 787 | 792 | oxygenhub check --data DIR verify every repository's objects and signatures, report |
|
| … | 49 unchanged lines |