OXYGENHUB

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(&note).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(&note).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