OXYGENHUB
# dd327d76062d oxygenhub / crates / hub / src / leader.rs
14.2 KiB · Rust · last changed One auto-land and one Diffusion bump at a time across hub processes (closes #275) 15 days ago source blame history raw
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
/// A lock across every process sharing the hub's database, held while the
26
/// guard lives (#275): one evaluation of a proposal's auto-land, one bump
27
/// or landing of a consumer's pin, at a time anywhere. On PostgreSQL a
28
/// transaction-scoped advisory lock (`pg_advisory_xact_lock`), freed when
29
/// the guard drops and its transaction ends, or with its connection; on
30
/// SQLite (one process by definition) a lock in this process.
31
pub struct Exclusive {
32
    #[cfg(feature = "postgres")]
33
    _tx: sqlx::Transaction<'static, sqlx::Postgres>,
34
    #[cfg(not(feature = "postgres"))]
35
    _guard: tokio::sync::OwnedMutexGuard<()>,
36
}
37
38
/// Wait for the lock named `name`, then hold it until the guard drops.
39
#[cfg(feature = "postgres")]
40
pub async fn exclusive(pool: &crate::db::Pool, name: &str) -> sqlx::Result<Exclusive> {
41
    let mut tx = pool.begin().await?;
42
    let schema: String = sqlx::query_scalar("SELECT CAST(current_schema() AS TEXT)").fetch_one(&mut *tx).await?;
43
    sqlx::query("SELECT pg_advisory_xact_lock($1)").bind(pg::key(&schema, name)).execute(&mut *tx).await?;
44
    Ok(Exclusive { _tx: tx })
45
}
46
47
/// Wait for the lock named `name`, then hold it until the guard drops.
48
#[cfg(not(feature = "postgres"))]
49
pub async fn exclusive(_pool: &crate::db::Pool, name: &str) -> sqlx::Result<Exclusive> {
50
    static LOCAL: Mutex<Option<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> = Mutex::new(None);
51
    let lock = {
52
        let mut map = LOCAL.lock().unwrap_or_else(|p| p.into_inner());
53
        let map = map.get_or_insert_with(Default::default);
54
        // Locks nobody holds or waits for are forgotten.
55
        map.retain(|_, l| std::sync::Arc::strong_count(l) > 1);
56
        map.entry(name.to_string()).or_default().clone()
57
    };
58
    Ok(Exclusive { _guard: lock.lock_owned().await })
59
}
60
61
/// How often a process that does not hold a lock asks for it again.
62
pub const RETRY: Duration = Duration::from_secs(2);
63
64
/// How often a holder makes sure its connection, and so its lock, is still
65
/// there; and how long it waits for the database's answer.
66
pub const CHECK: Duration = Duration::from_secs(2);
67
68
/// This process's background tasks and the locks it holds.
69
#[derive(Default)]
70
pub struct Leader {
71
    /// Every background task this process started, led or not.
72
    tasks: Mutex<BTreeSet<&'static str>>,
73
    /// The singleton tasks running here now: the locks this process holds.
74
    leading: Mutex<BTreeSet<&'static str>>,
75
    /// The loops contending for locks, stopped by [`Leader::resign`].
76
    contenders: Mutex<Vec<tokio::task::AbortHandle>>,
77
    #[cfg(feature = "postgres")]
78
    session: pg::Session,
79
}
80
81
impl Leader {
82
    /// The background tasks this process started, by name.
83
    pub fn tasks(&self) -> Vec<&'static str> {
84
        self.tasks.lock().unwrap_or_else(|p| p.into_inner()).iter().copied().collect()
85
    }
86
87
    /// The leader locks this process holds: the singleton tasks running here.
88
    pub fn leading(&self) -> Vec<&'static str> {
89
        self.leading.lock().unwrap_or_else(|p| p.into_inner()).iter().copied().collect()
90
    }
91
92
    /// Record a background task that needs no lock (it is safe anywhere).
93
    pub fn note(&self, name: &'static str) {
94
        self.tasks.lock().unwrap_or_else(|p| p.into_inner()).insert(name);
95
    }
96
97
    /// Stop every singleton task here and give up their locks, as a process
98
    /// that stops does: another process takes them over.
99
    pub async fn resign(&self) {
100
        for c in self.contenders.lock().unwrap_or_else(|p| p.into_inner()).drain(..) {
101
            c.abort();
102
        }
103
        #[cfg(feature = "postgres")]
104
        self.session.close().await;
105
    }
106
107
    /// The process id of the database session holding this process's
108
    /// locks, if it has one (for tests that end it).
109
    #[cfg(feature = "postgres")]
110
    pub async fn session_pid(&self) -> Option<i32> {
111
        self.session.pid().await
112
    }
113
}
114
115
// Taking, checking and giving back a lock: on PostgreSQL through the
116
// session; on SQLite every lock is this process's.
117
#[cfg(feature = "postgres")]
118
impl Leader {
119
    /// Take `name`'s lock now if it is free: the session's generation it
120
    /// was taken in, which [`Leader::held`] checks against.
121
    async fn acquire(&self, app: &Shared, name: &'static str) -> Option<u64> {
122
        self.session.acquire(&app.db, name).await
123
    }
124
125
    /// Whether a lock taken in generation `term` is still held.
126
    async fn held(&self, term: u64) -> bool {
127
        self.session.held(term).await
128
    }
129
130
    /// Give `name`'s lock back, its task done.
131
    async fn release(&self, name: &'static str, term: u64) {
132
        self.session.release(name, term).await
133
    }
134
}
135
136
#[cfg(not(feature = "postgres"))]
137
impl Leader {
138
    async fn acquire(&self, _app: &Shared, _name: &'static str) -> Option<u64> {
139
        Some(0)
140
    }
141
142
    async fn held(&self, _term: u64) -> bool {
143
        true
144
    }
145
146
    async fn release(&self, _name: &'static str, _term: u64) {}
147
}
148
149
/// Marks a task as led here while it runs; dropping it (the task ended,
150
/// was stopped, or the loop was aborted) unmarks it.
151
struct Leading<'a>(&'a Leader, &'static str);
152
153
impl<'a> Leading<'a> {
154
    fn new(leader: &'a Leader, name: &'static str) -> Self {
155
        leader.leading.lock().unwrap_or_else(|p| p.into_inner()).insert(name);
156
        Leading(leader, name)
157
    }
158
}
159
160
impl Drop for Leading<'_> {
161
    fn drop(&mut self) {
162
        self.0.leading.lock().unwrap_or_else(|p| p.into_inner()).remove(self.1);
163
    }
164
}
165
166
/// Run the singleton task `name` in the background, only while this
167
/// process holds its lock. `task` makes the task's future; it is made
168
/// again each time the lock is taken after being lost. A task that ends by
169
/// itself gives its lock back and is not run again.
170
pub fn run<F, Fut>(app: &Shared, name: &'static str, task: F)
171
where
172
    F: Fn(Shared) -> Fut + Send + 'static,
173
    Fut: Future<Output = ()> + Send + 'static,
174
{
175
    app.leader.note(name);
176
    let handle = tokio::spawn(contend(app.clone(), name, task));
177
    app.leader.contenders.lock().unwrap_or_else(|p| p.into_inner()).push(handle.abort_handle());
178
}
179
180
async fn contend<F, Fut>(app: Shared, name: &'static str, task: F)
181
where
182
    F: Fn(Shared) -> Fut,
183
    Fut: Future<Output = ()>,
184
{
185
    let leader = &app.leader;
186
    let mut waiting = false;
187
    loop {
188
        let Some(term) = leader.acquire(&app, name).await else {
189
            if !waiting {
190
                tracing::info!("leader: {name} runs elsewhere; standing by");
191
                waiting = true;
192
            }
193
            tokio::time::sleep(RETRY).await;
194
            continue;
195
        };
196
        if waiting {
197
            tracing::info!("leader: took over {name}");
198
            waiting = false;
199
        }
200
        let _leading = Leading::new(leader, name);
201
        let work = task(app.clone());
202
        tokio::pin!(work);
203
        let finished = loop {
204
            tokio::select! {
205
                _ = &mut work => break true,
206
                _ = tokio::time::sleep(CHECK) => {
207
                    if !leader.held(term).await {
208
                        break false;
209
                    }
210
                }
211
            }
212
        };
213
        if finished {
214
            leader.release(name, term).await;
215
            return;
216
        }
217
        // The future is dropped here: the task stops where it was.
218
        tracing::warn!("leader: lost the lock for {name}; stopped it, contending again");
219
    }
220
}
221
222
#[cfg(feature = "postgres")]
223
mod pg {
224
    use super::CHECK;
225
    use crate::db::Pool;
226
    use sqlx::postgres::PgConnection;
227
    use sqlx::Connection;
228
    use std::time::Instant;
229
230
    /// The connection holding this process's locks.
231
    struct Conn {
232
        conn: PgConnection,
233
        /// The schema the hub's tables are in: two hubs in two schemas of
234
        /// one database are two hubs, and do not share locks.
235
        schema: String,
236
        pid: i32,
237
        /// When the database last answered on it.
238
        checked: Instant,
239
    }
240
241
    #[derive(Default)]
242
    pub(super) struct Session {
243
        conn: tokio::sync::Mutex<Option<Conn>>,
244
        /// Counts connections lost: a lock taken in an earlier generation
245
        /// went with its connection.
246
        term: std::sync::atomic::AtomicU64,
247
        closed: std::sync::atomic::AtomicBool,
248
    }
249
250
    /// The lock's key: a stable 64-bit hash of the schema and the name.
251
    pub(super) fn key(schema: &str, name: &str) -> i64 {
252
        let h = blake3::hash(format!("oxygenhub leader\0{schema}\0{name}").as_bytes());
253
        i64::from_le_bytes(h.as_bytes()[..8].try_into().expect("eight bytes"))
254
    }
255
256
    impl Session {
257
        fn term(&self) -> u64 {
258
            self.term.load(std::sync::atomic::Ordering::SeqCst)
259
        }
260
261
        /// The connection is gone, and every lock with it.
262
        fn lose(&self, slot: &mut Option<Conn>) {
263
            if slot.take().is_some() {
264
                self.term.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
265
            }
266
        }
267
268
        async fn connect(db: &Pool) -> anyhow::Result<Conn> {
269
            let options = db.connect_options();
270
            let mut conn = PgConnection::connect_with(&options).await?;
271
            let (schema, pid): (String, i32) =
272
                sqlx::query_as("SELECT CAST(current_schema() AS TEXT), pg_backend_pid()").fetch_one(&mut conn).await?;
273
            Ok(Conn { conn, schema, pid, checked: Instant::now() })
274
        }
275
276
        pub(super) async fn acquire(&self, db: &Pool, name: &'static str) -> Option<u64> {
277
            let mut slot = self.conn.lock().await;
278
            if self.closed.load(std::sync::atomic::Ordering::SeqCst) {
279
                return None;
280
            }
281
            if slot.is_none() {
282
                match tokio::time::timeout(CHECK * 5, Self::connect(db)).await {
283
                    Ok(Ok(c)) => *slot = Some(c),
284
                    Ok(Err(e)) => {
285
                        tracing::warn!("leader: connecting for {name}'s lock: {e}");
286
                        return None;
287
                    }
288
                    Err(_) => {
289
                        tracing::warn!("leader: connecting for {name}'s lock: timed out");
290
                        return None;
291
                    }
292
                }
293
            }
294
            let term = self.term();
295
            let c = slot.as_mut()?;
296
            let ask = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)").bind(key(&c.schema, name));
297
            match tokio::time::timeout(CHECK, ask.fetch_one(&mut c.conn)).await {
298
                Ok(Ok(got)) => {
299
                    c.checked = Instant::now();
300
                    got.then_some(term)
301
                }
302
                Ok(Err(e)) => {
303
                    tracing::warn!("leader: asking for {name}'s lock: {e}");
304
                    self.lose(&mut slot);
305
                    None
306
                }
307
                Err(_) => {
308
                    tracing::warn!("leader: asking for {name}'s lock: timed out");
309
                    self.lose(&mut slot);
310
                    None
311
                }
312
            }
313
        }
314
315
        pub(super) async fn held(&self, term: u64) -> bool {
316
            let mut slot = self.conn.lock().await;
317
            if self.term() != term {
318
                return false;
319
            }
320
            let Some(c) = slot.as_mut() else { return false };
321
            // One answer serves every task's check made soon after it.
322
            if c.checked.elapsed() < CHECK / 2 {
323
                return true;
324
            }
325
            let ping = sqlx::query_scalar::<_, i32>("SELECT 1").fetch_one(&mut c.conn);
326
            match tokio::time::timeout(CHECK, ping).await {
327
                Ok(Ok(_)) => {
328
                    c.checked = Instant::now();
329
                    true
330
                }
331
                _ => {
332
                    tracing::warn!("leader: the connection holding the locks is gone");
333
                    self.lose(&mut slot);
334
                    false
335
                }
336
            }
337
        }
338
339
        pub(super) async fn release(&self, name: &'static str, term: u64) {
340
            let mut slot = self.conn.lock().await;
341
            if self.term() != term {
342
                return;
343
            }
344
            let Some(c) = slot.as_mut() else { return };
345
            let unlock = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)").bind(key(&c.schema, name));
346
            if !matches!(tokio::time::timeout(CHECK, unlock.fetch_one(&mut c.conn)).await, Ok(Ok(_))) {
347
                self.lose(&mut slot);
348
            }
349
        }
350
351
        pub(super) async fn close(&self) {
352
            self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
353
            let mut slot = self.conn.lock().await;
354
            if let Some(c) = slot.take() {
355
                self.term.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
356
                let _ = c.conn.close().await;
357
            }
358
        }
359
360
        pub(super) async fn pid(&self) -> Option<i32> {
361
            self.conn.lock().await.as_ref().map(|c| c.pid)
362
        }
363
    }
364
365
    #[cfg(test)]
366
    mod tests {
367
        #[test]
368
        fn keys_are_stable_and_per_schema() {
369
            assert_eq!(super::key("hub_a", "flow.scheduler"), super::key("hub_a", "flow.scheduler"));
370
            assert_ne!(super::key("hub_a", "flow.scheduler"), super::key("hub_b", "flow.scheduler"));
371
            assert_ne!(super::key("hub_a", "flow.scheduler"), super::key("hub_a", "flow.reaper"));
372
        }
373
    }
374
}