OXYGENHUB
◆ v0.9.0 oxygenhub / crates / hub / tests / roles.rs
11.4 KiB · Rust · last changed Board cards can be archived, by hand or by age (refs #293) 10 days ago source blame history raw
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; 9] = [
16
    "backups.scheduler",
17
    "diffusion.reconciler",
18
    "flow.placement",
19
    "flow.reaper",
20
    "flow.scheduler",
21
    "hooks.worker",
22
    "maintenance.nightly",
23
    "projects.archiver",
24
    "secrets.history_scan",
25
];
26
27
fn hermetic() {
28
    static ONCE: std::sync::Once = std::sync::Once::new();
29
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
30
    ONCE.call_once(|| {
31
        let d = tempfile::tempdir().unwrap();
32
        std::env::set_var("OXY_HOME", d.path());
33
        DIR.set(d).ok();
34
    });
35
}
36
37
fn config(dir: &Path, roles: &[&str]) -> Config {
38
    let mut config = Config::load(dir).unwrap();
39
    config.server.roles = roles.iter().map(|r| r.to_string()).collect();
40
    config.registration.setup_code = "test-setup".into();
41
    config
42
}
43
44
async fn get(app: &Shared, path: &str, token: Option<&str>) -> (StatusCode, String) {
45
    let mut req = Request::get(path);
46
    if let Some(t) = token {
47
        req = req.header(header::AUTHORIZATION, format!("Bearer {t}"));
48
    }
49
    let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::empty()).unwrap()).await.unwrap();
50
    let status = res.status();
51
    let body = res.into_body().collect().await.unwrap().to_bytes();
52
    (status, String::from_utf8_lossy(&body).into_owned())
53
}
54
55
async fn health(app: &Shared) -> serde_json::Value {
56
    let (status, body) = get(app, "/health", None).await;
57
    assert_eq!(status, StatusCode::OK, "{body}");
58
    serde_json::from_str(&body).unwrap()
59
}
60
61
/// Wait up to `limit` for `ok`, checking every 100 ms.
62
async fn until(limit: Duration, mut ok: impl FnMut() -> bool) -> bool {
63
    let start = Instant::now();
64
    while start.elapsed() < limit {
65
        if ok() {
66
            return true;
67
        }
68
        tokio::time::sleep(Duration::from_millis(100)).await;
69
    }
70
    ok()
71
}
72
73
#[tokio::test(flavor = "multi_thread")]
74
async fn a_web_only_hub_starts_none_of_the_workers_tasks() {
75
    hermetic();
76
    let dir = tempfile::tempdir().unwrap();
77
    let app = oxygenhub::build(config(dir.path(), &["web"])).await.unwrap();
78
    // Only the transparency log's checkpointer, which goes with the log's
79
    // appends in the web role.
80
    assert_eq!(app.leader.tasks(), ["tlog.checkpointer"]);
81
    assert!(until(Duration::from_secs(5), || app.leader.leading() == ["tlog.checkpointer"]).await, "{:?}", app.leader.leading());
82
    let h = health(&app).await;
83
    assert_eq!(h["roles"], serde_json::json!(["web"]));
84
    assert_eq!(h["leading"], serde_json::json!(["tlog.checkpointer"]));
85
    // It serves pages.
86
    let (status, body) = get(&app, "/explore", None).await;
87
    assert_eq!(status, StatusCode::OK, "{body}");
88
}
89
90
#[tokio::test(flavor = "multi_thread")]
91
async fn a_worker_only_hub_serves_its_health_and_metrics_and_no_pages() {
92
    hermetic();
93
    let dir = tempfile::tempdir().unwrap();
94
    let mut cfg = config(dir.path(), &["worker"]);
95
    cfg.server.metrics_token = "m".into();
96
    let app = oxygenhub::build(cfg).await.unwrap();
97
    let mut tasks = WORKER_SINGLETONS.to_vec();
98
    tasks.extend(["diffusion.backfill", "images.learn_archs", "queue.worker"]);
99
    tasks.sort();
100
    assert_eq!(app.leader.tasks(), tasks, "every worker task, and not the log's checkpointer");
101
    assert!(
102
        until(Duration::from_secs(5), || WORKER_SINGLETONS.iter().all(|n| app.leader.leading().contains(n))).await,
103
        "{:?}",
104
        app.leader.leading()
105
    );
106
    let h = health(&app).await;
107
    assert_eq!(h["roles"], serde_json::json!(["worker"]));
108
    assert!(h["leading"].as_array().unwrap().iter().any(|n| n == "flow.scheduler"), "{h}");
109
    for path in ["/", "/explore", "/login", "/api/v1/version", "/andy/demo", "/log/checkpoint"] {
110
        assert_eq!(get(&app, path, None).await.0, StatusCode::NOT_FOUND, "{path} is the web role's");
111
    }
112
    let (status, body) = get(&app, "/metrics", Some("m")).await;
113
    assert_eq!(status, StatusCode::OK, "{body}");
114
    // Flow's job times (oxygenflow#35) and the work queue (#276).
115
    for family in [
116
        "# TYPE oxyhub_flow_job_queue_seconds histogram",
117
        "# TYPE oxyhub_flow_job_run_seconds histogram",
118
        "oxyhub_flow_jobs_reused_last_day 0",
119
        "oxyhub_queue_items{state=\"dead\"} 0",
120
    ] {
121
        assert!(body.contains(family), "{family}: {body}");
122
    }
123
    // It reads the transparency log but never writes it.
124
    assert!(app.tlog.append(&[]).is_err());
125
}
126
127
/// oxygenhub#275: a named lock is held by one holder at a time across the
128
/// database's sessions (on SQLite, the process); other names are free.
129
#[tokio::test(flavor = "multi_thread")]
130
async fn an_exclusive_lock_has_one_holder_at_a_time() {
131
    hermetic();
132
    let dir = tempfile::tempdir().unwrap();
133
    let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap();
134
    let held = oxygenhub::leader::exclusive(&app.db, "autoland:7").await.unwrap();
135
    let (tx, mut rx) = tokio::sync::oneshot::channel();
136
    let db = app.db.clone();
137
    tokio::spawn(async move {
138
        let g = oxygenhub::leader::exclusive(&db, "autoland:7").await.unwrap();
139
        let _ = tx.send(());
140
        drop(g);
141
    });
142
    tokio::time::sleep(Duration::from_millis(300)).await;
143
    assert!(rx.try_recv().is_err(), "the second holder waits");
144
    let other = tokio::time::timeout(Duration::from_secs(2), oxygenhub::leader::exclusive(&app.db, "autoland:8")).await;
145
    assert!(other.is_ok(), "another name is free");
146
    drop(other);
147
    drop(held);
148
    assert!(tokio::time::timeout(Duration::from_secs(5), rx).await.is_ok(), "and takes it once it is let go");
149
}
150
151
/// oxygenhub#276: the work queue. A keyed item is queued once; a claim
152
/// leases it (another claim skips it); a failure backs off and the last
153
/// attempt leaves it dead; done removes it. An item no worker knows is dead
154
/// at once.
155
#[tokio::test(flavor = "multi_thread")]
156
async fn the_work_queue_leases_retries_and_keeps_the_dead() {
157
    use oxygenhub::db::queue;
158
    hermetic();
159
    let dir = tempfile::tempdir().unwrap();
160
    let app = oxygenhub::build(config(dir.path(), &["web"])).await.unwrap();
161
    let payload = serde_json::json!({ "repo_id": 7 });
162
    assert!(queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap());
163
    assert!(!queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap(), "once per key");
164
    let got = queue::claim(&app.db, "a", 10, 60_000).await.unwrap();
165
    assert_eq!((got.len(), got[0].attempts, got[0].claimed_by.as_str()), (1, 1, "a"));
166
    assert!(queue::claim(&app.db, "b", 10, 60_000).await.unwrap().is_empty(), "leased to a");
167
    queue::failed(&app.db, &got[0], "the disk is full").await.unwrap();
168
    assert!(queue::claim(&app.db, "b", 10, 60_000).await.unwrap().is_empty(), "backing off");
169
    sqlx::query("UPDATE work_queue SET run_after = 0, attempts = $1").bind(queue::MAX_ATTEMPTS - 1).execute(&app.db).await.unwrap();
170
    let last = queue::claim(&app.db, "b", 10, 60_000).await.unwrap();
171
    assert_eq!(last[0].attempts, queue::MAX_ATTEMPTS);
172
    queue::failed(&app.db, &last[0], "still full").await.unwrap();
173
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 1), "dead after the last attempt");
174
    // A dead item does not hold its key: the same work queues again.
175
    assert!(queue::enqueue(&app.db, "mirror.refresh", &payload, "7", 0).await.unwrap());
176
    let item = queue::claim(&app.db, "a", 10, 60_000).await.unwrap().remove(0);
177
    // Repository 7 does not exist: nothing to refresh, so it is done.
178
    oxygenhub::queue::run(&app, item).await;
179
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 1));
180
    queue::enqueue(&app.db, "no.such.kind", &serde_json::json!({}), "", 0).await.unwrap();
181
    let item = queue::claim(&app.db, "a", 10, 60_000).await.unwrap().remove(0);
182
    oxygenhub::queue::run(&app, item).await;
183
    assert_eq!(queue::counts(&app.db).await.unwrap(), (0, 2), "an unknown kind is dead at once");
184
}
185
186
#[tokio::test(flavor = "multi_thread")]
187
async fn a_hub_takes_every_role_by_default() {
188
    hermetic();
189
    let dir = tempfile::tempdir().unwrap();
190
    let app = oxygenhub::build(config(dir.path(), &[])).await.unwrap();
191
    assert_eq!(app.leader.tasks().len(), 13, "{:?}", app.leader.tasks());
192
    assert_eq!(health(&app).await["roles"], serde_json::json!(["web", "worker"]));
193
    assert_eq!(get(&app, "/explore", None).await.0, StatusCode::OK);
194
    let mut bad = config(dir.path(), &["web", "cron"]);
195
    bad.server.roles.push(String::new());
196
    let e = oxygenhub::build(bad).await.err().expect("an unknown role is refused").to_string();
197
    assert!(e.contains("unknown role \"cron\""), "{e}");
198
}
199
200
/// Two hubs on one PostgreSQL database (one schema): exactly one leads
201
/// each singleton; when its connection drops it stops them, and when it
202
/// stops the other takes every one over within seconds.
203
#[cfg(feature = "postgres")]
204
#[tokio::test(flavor = "multi_thread")]
205
async fn two_hubs_on_one_database_run_each_singleton_once_and_fail_over() {
206
    hermetic();
207
    let (da, dbb) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
208
    let schema = format!("hub_roles_{}", da.path().file_name().unwrap().to_string_lossy());
209
    let pg = |dir: &Path| {
210
        let mut c = config(dir, &[]);
211
        c.database.schema = schema.clone();
212
        c
213
    };
214
    let a = oxygenhub::build(pg(da.path())).await.unwrap();
215
    let b = oxygenhub::build(pg(dbb.path())).await.unwrap();
216
    let mut singletons = WORKER_SINGLETONS.to_vec();
217
    singletons.push("tlog.checkpointer");
218
    let once = |a: &Shared, b: &Shared| {
219
        let (la, lb) = (a.leader.leading(), b.leader.leading());
220
        singletons.iter().all(|n| la.contains(n) != lb.contains(n))
221
    };
222
    assert!(until(Duration::from_secs(10), || once(&a, &b)).await, "a {:?}, b {:?}", a.leader.leading(), b.leader.leading());
223
224
    // A's connection is ended, as when the network drops: its locks free,
225
    // it stops what it led, and between them each runs once again.
226
    let pid = a.leader.session_pid().await.expect("a has a session");
227
    let ended: bool = sqlx::query_scalar("SELECT pg_terminate_backend($1)").bind(pid).fetch_one(&b.db).await.unwrap();
228
    assert!(ended);
229
    let start = Instant::now();
230
    loop {
231
        let reconnected = a.leader.session_pid().await.is_some_and(|p| p != pid);
232
        if reconnected && once(&a, &b) {
233
            break;
234
        }
235
        assert!(start.elapsed() < Duration::from_secs(15), "a {:?}, b {:?}", a.leader.leading(), b.leader.leading());
236
        tokio::time::sleep(Duration::from_millis(100)).await;
237
    }
238
239
    // The hub leading the scheduler stops; the other takes everything over.
240
    let (gone, left) = if a.leader.leading().contains(&"flow.scheduler") { (&a, &b) } else { (&b, &a) };
241
    gone.leader.resign().await;
242
    let start = Instant::now();
243
    assert!(
244
        until(Duration::from_secs(10), || singletons.iter().all(|n| left.leader.leading().contains(n))).await,
245
        "left {:?}",
246
        left.leader.leading()
247
    );
248
    eprintln!("took over in {:.1} s", start.elapsed().as_secs_f64());
249
    assert!(gone.leader.leading().is_empty(), "{:?}", gone.leader.leading());
250
    assert_eq!(health(left).await["leading"].as_array().unwrap().len(), left.leader.leading().len());
251
}