Skip to main content

prospero_core/
leased_ownership.rs

1//! Clustered single-writer ownership via a Postgres lease row per stream.
2//!
3//! One row per active stream — `(stream_key, owner_replica_id, epoch,
4//! expires_at)`. `try_acquire` is `INSERT … ON CONFLICT … WHERE expired-or-ours`
5//! (so it both claims a free stream and reaps an expired one — the shared poll
6//! loop is the reaper/takeover, spec §3.3); the owner extends `expires_at` via
7//! `heartbeat`/`renew`; `renew` failing is how a replica learns its lease was
8//! stolen. Expiry uses the Postgres clock (`now()` + `make_interval`) so it is
9//! immune to per-replica clock skew. `owns` answers from an in-memory mirror of
10//! held leases (cheap, for the poll hot path); the `UNIQUE(stream_key, seq)`
11//! store constraint plus the `epoch` fencing token are the two-writer backstops.
12
13use std::collections::HashMap;
14use std::sync::Mutex;
15
16use sqlx::Row;
17use sqlx::postgres::PgPool;
18
19use crate::Result;
20use crate::error::CoreError;
21use crate::ownership::{Lease, Ownership};
22
23const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS leases (\
24    stream_key TEXT PRIMARY KEY,\
25    owner_replica_id TEXT NOT NULL,\
26    epoch BIGINT NOT NULL,\
27    expires_at TIMESTAMPTZ NOT NULL)";
28
29/// Clustered `Ownership`: a Postgres lease per stream (spec §3.3).
30pub struct LeasedOwnership {
31    pool: PgPool,
32    replica_id: String,
33    /// Lease TTL in seconds (DB-clock relative). The daemon must call
34    /// [`LeasedOwnership::heartbeat`] well within this window.
35    ttl_secs: f64,
36    /// In-memory mirror of leases this replica believes it holds: key → epoch.
37    held: Mutex<HashMap<String, u64>>,
38}
39
40impl LeasedOwnership {
41    /// Connect, ensure the lease table exists, and identify this replica.
42    /// `ttl_secs` is the lease lifetime; call [`Self::heartbeat`] well within it.
43    pub async fn connect(url: &str, replica_id: String, ttl_secs: f64) -> Result<Self> {
44        let pool = crate::pg::connect(url).await?;
45        crate::pg::ensure_schema(&pool, SCHEMA, "leases table").await?;
46        Ok(Self {
47            pool,
48            replica_id,
49            ttl_secs,
50            held: Mutex::new(HashMap::new()),
51        })
52    }
53
54    /// Build on an existing pool (shared-pool wiring; the daemon uses a pool per
55    /// seam today, so this is for a future single-pool setup).
56    pub fn new(pool: PgPool, replica_id: String, ttl_secs: f64) -> Self {
57        Self {
58            pool,
59            replica_id,
60            ttl_secs,
61            held: Mutex::new(HashMap::new()),
62        }
63    }
64
65    /// Renew every lease this replica holds; drop any it has lost. The daemon's
66    /// reconciliation tick calls this (Phase 2d).
67    pub async fn heartbeat(&self) {
68        let leases: Vec<Lease> = {
69            let held = self.held.lock().unwrap();
70            held.iter()
71                .map(|(k, e)| Lease {
72                    stream_key: k.clone(),
73                    epoch: *e,
74                })
75                .collect()
76        };
77        for lease in leases {
78            if self.renew(&lease).await.is_err() {
79                self.held.lock().unwrap().remove(&lease.stream_key);
80                tracing::warn!(
81                    target: "prospero_ownership",
82                    stream = %lease.stream_key, "lease lost; dropping ownership"
83                );
84            }
85        }
86    }
87
88    #[cfg(any(test, feature = "testkit"))]
89    pub async fn reset_for_tests(&self) -> Result<()> {
90        sqlx::query("TRUNCATE leases")
91            .execute(&self.pool)
92            .await
93            .map_err(|e| CoreError::Store(format!("truncating leases: {e}")))?;
94        self.held.lock().unwrap().clear();
95        Ok(())
96    }
97
98    /// Test helper: unconditionally take a stream for this replica (used to
99    /// simulate a takeover by a peer without waiting out a TTL).
100    #[cfg(any(test, feature = "testkit"))]
101    pub async fn force_steal(&self, stream_key: &str) {
102        let row = sqlx::query(
103            "INSERT INTO leases (stream_key, owner_replica_id, epoch, expires_at) \
104             VALUES ($1, $2, 1, now() + make_interval(secs => $3)) \
105             ON CONFLICT (stream_key) DO UPDATE \
106                SET owner_replica_id = excluded.owner_replica_id, \
107                    epoch = leases.epoch + 1, \
108                    expires_at = excluded.expires_at \
109             RETURNING epoch",
110        )
111        .bind(stream_key)
112        .bind(&self.replica_id)
113        .bind(self.ttl_secs)
114        .fetch_one(&self.pool)
115        .await
116        .expect("force_steal");
117        let epoch: i64 = row.get("epoch");
118        self.held
119            .lock()
120            .unwrap()
121            .insert(stream_key.to_string(), epoch as u64);
122    }
123}
124
125#[async_trait::async_trait]
126impl Ownership for LeasedOwnership {
127    async fn try_acquire(&self, stream_key: &str) -> Option<Lease> {
128        // Claim a free key, reap an expired one, or idempotently re-confirm our
129        // own. Epoch is kept when re-acquiring ours, bumped when stealing an
130        // expired lease (fences the dead owner). The WHERE makes the UPDATE — and
131        // thus the RETURNING row — vanish when another live replica owns it.
132        let row = sqlx::query(
133            "INSERT INTO leases (stream_key, owner_replica_id, epoch, expires_at) \
134             VALUES ($1, $2, 1, now() + make_interval(secs => $3)) \
135             ON CONFLICT (stream_key) DO UPDATE \
136                SET owner_replica_id = excluded.owner_replica_id, \
137                    epoch = CASE WHEN leases.owner_replica_id = excluded.owner_replica_id \
138                                 THEN leases.epoch ELSE leases.epoch + 1 END, \
139                    expires_at = excluded.expires_at \
140             WHERE leases.expires_at < now() \
141                OR leases.owner_replica_id = excluded.owner_replica_id \
142             RETURNING epoch",
143        )
144        .bind(stream_key)
145        .bind(&self.replica_id)
146        .bind(self.ttl_secs)
147        .fetch_optional(&self.pool)
148        .await
149        .unwrap_or_else(|e| {
150            tracing::warn!(target: "prospero_ownership", stream = %stream_key, error = %e, "try_acquire failed");
151            None
152        })?;
153        let epoch: i64 = row.get("epoch");
154        let epoch = epoch as u64;
155        self.held
156            .lock()
157            .unwrap()
158            .insert(stream_key.to_string(), epoch);
159        Some(Lease {
160            stream_key: stream_key.to_string(),
161            epoch,
162        })
163    }
164
165    async fn renew(&self, lease: &Lease) -> Result<()> {
166        // Extend only while we still hold it at the SAME epoch — a steal advances
167        // owner/epoch, so 0 rows affected means we lost the lease.
168        let res = sqlx::query(
169            "UPDATE leases SET expires_at = now() + make_interval(secs => $1) \
170             WHERE stream_key = $2 AND owner_replica_id = $3 AND epoch = $4",
171        )
172        .bind(self.ttl_secs)
173        .bind(&lease.stream_key)
174        .bind(&self.replica_id)
175        .bind(lease.epoch as i64)
176        .execute(&self.pool)
177        .await
178        .map_err(|e| CoreError::Store(format!("renewing lease: {e}")))?;
179        if res.rows_affected() == 0 {
180            return Err(CoreError::Store(format!(
181                "lease for {} lost (stolen or expired)",
182                lease.stream_key
183            )));
184        }
185        Ok(())
186    }
187
188    async fn release(&self, stream_key: &str) {
189        self.held.lock().unwrap().remove(stream_key);
190        if let Err(e) =
191            sqlx::query("DELETE FROM leases WHERE stream_key = $1 AND owner_replica_id = $2")
192                .bind(stream_key)
193                .bind(&self.replica_id)
194                .execute(&self.pool)
195                .await
196        {
197            tracing::warn!(target: "prospero_ownership", stream = %stream_key, error = %e, "releasing lease failed");
198        }
199    }
200
201    fn owns(&self, stream_key: &str) -> bool {
202        self.held.lock().unwrap().contains_key(stream_key)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use std::sync::atomic::{AtomicU64, Ordering};
210    use std::time::{SystemTime, UNIX_EPOCH};
211
212    fn unique(tag: &str) -> String {
213        static N: AtomicU64 = AtomicU64::new(0);
214        let nanos = SystemTime::now()
215            .duration_since(UNIX_EPOCH)
216            .unwrap()
217            .as_nanos();
218        format!("{tag}-{nanos}-{}", N.fetch_add(1, Ordering::Relaxed))
219    }
220
221    async fn owner(url: &str, ttl_secs: f64) -> LeasedOwnership {
222        LeasedOwnership::connect(url, unique("replica"), ttl_secs)
223            .await
224            .unwrap()
225    }
226
227    macro_rules! db_url {
228        ($name:literal) => {
229            match std::env::var("DATABASE_URL") {
230                Ok(u) => u,
231                Err(_) => {
232                    eprintln!(concat!("SKIP ", $name, ": DATABASE_URL unset"));
233                    return;
234                }
235            }
236        };
237    }
238
239    #[tokio::test]
240    async fn acquires_a_free_stream_and_owns_it() {
241        let url = db_url!("acquires_a_free_stream_and_owns_it");
242        let o = owner(&url, 30.0).await;
243        let key = unique("s");
244        let lease = o.try_acquire(&key).await.expect("free stream acquires");
245        assert_eq!(lease.stream_key, key);
246        assert!(lease.epoch >= 1);
247        assert!(o.owns(&key));
248    }
249
250    #[tokio::test]
251    async fn reacquiring_own_live_lease_is_idempotent_and_keeps_epoch() {
252        let url = db_url!("reacquiring_own_live_lease_is_idempotent_and_keeps_epoch");
253        let o = owner(&url, 30.0).await;
254        let key = unique("s");
255        let first = o.try_acquire(&key).await.unwrap();
256        let again = o.try_acquire(&key).await.expect("own lease re-acquires");
257        assert_eq!(
258            again.epoch, first.epoch,
259            "re-acquiring your own lease must not bump epoch"
260        );
261    }
262
263    #[tokio::test]
264    async fn a_live_lease_blocks_another_replica() {
265        let url = db_url!("a_live_lease_blocks_another_replica");
266        let key = unique("s");
267        let a = owner(&url, 30.0).await;
268        let b = owner(&url, 30.0).await;
269        assert!(a.try_acquire(&key).await.is_some());
270        assert!(
271            b.try_acquire(&key).await.is_none(),
272            "peer must not steal a live lease"
273        );
274        assert!(!b.owns(&key));
275    }
276
277    #[tokio::test]
278    async fn an_expired_lease_is_stolen_with_a_bumped_epoch_and_renew_then_fails() {
279        let url = db_url!("an_expired_lease_is_stolen_with_a_bumped_epoch_and_renew_then_fails");
280        let key = unique("s");
281        let a = owner(&url, 1.0).await; // 1s TTL
282        let b = owner(&url, 30.0).await;
283        let a_lease = a.try_acquire(&key).await.unwrap();
284
285        tokio::time::sleep(std::time::Duration::from_millis(1200)).await; // let it expire
286
287        let b_lease = b.try_acquire(&key).await.expect("expired lease is stolen");
288        assert!(
289            b_lease.epoch > a_lease.epoch,
290            "takeover must bump the fencing epoch"
291        );
292        // The dethroned owner learns it lost the lease on its next renew.
293        assert!(
294            a.renew(&a_lease).await.is_err(),
295            "stale owner's renew must fail"
296        );
297        assert!(b.owns(&key));
298    }
299
300    #[tokio::test]
301    async fn renew_extends_a_held_lease() {
302        let url = db_url!("renew_extends_a_held_lease");
303        let o = owner(&url, 2.0).await;
304        let key = unique("s");
305        let lease = o.try_acquire(&key).await.unwrap();
306        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
307        o.renew(&lease).await.expect("owner renews its live lease");
308        // After renew the lease is good for another full TTL, so a peer can't steal.
309        let peer = owner(&url, 30.0).await;
310        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
311        assert!(
312            peer.try_acquire(&key).await.is_none(),
313            "renewed lease stays held"
314        );
315    }
316
317    #[tokio::test]
318    async fn release_frees_the_stream_for_a_peer() {
319        let url = db_url!("release_frees_the_stream_for_a_peer");
320        let key = unique("s");
321        let a = owner(&url, 30.0).await;
322        let b = owner(&url, 30.0).await;
323        a.try_acquire(&key).await.unwrap();
324        a.release(&key).await;
325        assert!(!a.owns(&key));
326        assert!(
327            b.try_acquire(&key).await.is_some(),
328            "released stream is claimable"
329        );
330    }
331
332    #[tokio::test]
333    async fn heartbeat_renews_all_held_and_drops_lost_leases() {
334        let url = db_url!("heartbeat_renews_all_held_and_drops_lost_leases");
335        let kept = unique("s");
336        let lost = unique("s");
337        let a = owner(&url, 2.0).await;
338        let thief = owner(&url, 30.0).await;
339        a.try_acquire(&kept).await.unwrap();
340        a.try_acquire(&lost).await.unwrap();
341        // A peer steals `lost` out from under `a` (simulating a takeover after a
342        // missed heartbeat). Stealing via an expired lease would mean waiting out
343        // a TTL, so force the takeover directly.
344        thief.force_steal(&lost).await;
345
346        a.heartbeat().await;
347        assert!(a.owns(&kept), "still-held lease survives heartbeat");
348        assert!(!a.owns(&lost), "heartbeat drops a lease lost to a peer");
349    }
350}