Skip to main content

gwk_kernel/
writer.rs

1//! Singleton write authority: the advisory boot lock and the durable epoch.
2//!
3//! Two independent guards, because they fail in different directions.
4//!
5//! The **advisory lock** is held for the process lifetime on its own dedicated
6//! connection, taken with the NONBLOCKING variant so a second kernel exits
7//! immediately with a clear message instead of hanging on a queue nobody is
8//! watching. PostgreSQL releases a session advisory lock when its session ends,
9//! so the lock is exactly as alive as that connection — which is why losing the
10//! connection and losing the lock are the same event, and why a dead probe is
11//! sufficient evidence of both.
12//!
13//! The **durable epoch** covers what the lock cannot. A process that was fenced
14//! out may still hold pooled connections with in-flight work; the lock says
15//! nothing about those. Bumping a row at boot and comparing it inside every
16//! mutating transaction makes such a write fail at COMMIT time rather than
17//! land behind its successor's back.
18
19use std::time::Duration;
20
21use secrecy::{ExposeSecret, SecretString};
22use sqlx::{Connection, Executor, PgConnection, PgPool, Row};
23use tokio::sync::watch;
24
25use crate::error::{KernelError, Result};
26
27/// The advisory-lock key for "this database's kernel writer".
28///
29/// Advisory locks share one namespace per database, so the key is a constant
30/// anything else in this database must avoid. It spells `gwk` in the high bytes
31/// and `wr1` in the low ones, which makes an unexpected holder identifiable in
32/// `pg_locks` instead of anonymous.
33pub const WRITER_LOCK_KEY: i64 = 0x0067_776b_0077_7231;
34
35/// How often the holder proves its connection is still alive.
36pub const LOCK_PROBE_INTERVAL: Duration = Duration::from_secs(5);
37
38/// The process-lifetime advisory lock, plus the signal that it is gone.
39///
40/// Dropping this aborts the probe and closes the connection, which releases the
41/// lock — so scope IS lifetime, and there is no "forgot to unlock" path.
42#[derive(Debug)]
43pub struct WriterLock {
44    cancelled: watch::Receiver<bool>,
45    probe: tokio::task::JoinHandle<()>,
46}
47
48impl WriterLock {
49    /// Take the lock, or refuse. Never waits: another live kernel means this
50    /// one must not start, and blocking would hide that behind a hang.
51    pub async fn acquire(database_url: &SecretString) -> Result<Self> {
52        Self::acquire_with_interval(database_url, LOCK_PROBE_INTERVAL).await
53    }
54
55    pub async fn acquire_with_interval(
56        database_url: &SecretString,
57        probe_interval: Duration,
58    ) -> Result<Self> {
59        let mut conn = PgConnection::connect(database_url.expose_secret()).await?;
60        let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
61            .bind(WRITER_LOCK_KEY)
62            .fetch_one(&mut conn)
63            .await?;
64        if !acquired {
65            return Err(KernelError::Writer(
66                "another kernel already holds the writer lock on this database; \
67                 exactly one process may write per store"
68                    .to_owned(),
69            ));
70        }
71
72        let (tx, cancelled) = watch::channel(false);
73        let probe = tokio::spawn(async move {
74            loop {
75                tokio::time::sleep(probe_interval).await;
76                // A session advisory lock dies with its session, so a failed
77                // round trip on THIS connection is proof the lock is gone.
78                // Nothing here tries to re-acquire: a kernel that lost write
79                // authority must stop, not race the process that took it.
80                if conn.execute("SELECT 1").await.is_err() {
81                    let _ = tx.send(true);
82                    return;
83                }
84                if tx.is_closed() {
85                    return;
86                }
87            }
88        });
89
90        Ok(Self { cancelled, probe })
91    }
92
93    /// True once write authority is gone. Callers stop accepting work.
94    pub fn is_cancelled(&self) -> bool {
95        *self.cancelled.borrow()
96    }
97
98    /// Resolves when write authority is lost. Never resolves while healthy, so
99    /// it composes in a `select!` beside the accept loop.
100    pub async fn cancelled(&self) {
101        let mut rx = self.cancelled.clone();
102        while !*rx.borrow_and_update() {
103            if rx.changed().await.is_err() {
104                return; // the probe is gone: treat that as lost authority too
105            }
106        }
107    }
108}
109
110impl Drop for WriterLock {
111    fn drop(&mut self) {
112        self.probe.abort();
113    }
114}
115
116/// Claim this boot's writer epoch: bump the durable counter under a row lock
117/// and return what this process must present from now on.
118///
119/// Every later mutating transaction compares against this value, so a process
120/// that booted before another one finds its epoch superseded and cannot commit.
121///
122/// No explicit `FOR UPDATE`: an `UPDATE` already takes a row lock, and under
123/// READ COMMITTED a blocked one re-evaluates against the committed row rather
124/// than its original snapshot — so simultaneous boots serialize and cannot be
125/// handed the same number. Verified against 16 with 40 concurrent claims, which
126/// returned exactly 1..40.
127pub async fn claim_epoch(pool: &PgPool) -> Result<i64> {
128    let row = sqlx::query(
129        "UPDATE gwk_internal.writer SET epoch = epoch + 1 WHERE id = 1 RETURNING epoch",
130    )
131    .fetch_optional(pool)
132    .await?
133    .ok_or_else(|| {
134        KernelError::Schema(
135            "gwk_internal.writer has no singleton row: this database was not initialized by \
136             `gw admin init`"
137                .to_owned(),
138        )
139    })?;
140    Ok(row.try_get("epoch")?)
141}