1use 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
29pub struct LeasedOwnership {
31 pool: PgPool,
32 replica_id: String,
33 ttl_secs: f64,
36 held: Mutex<HashMap<String, u64>>,
38}
39
40impl LeasedOwnership {
41 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 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 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 #[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 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 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; 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 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 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 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 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}