Skip to main content

reddb_server/runtime/
lease_lifecycle.rs

1//! Serverless writer-lease state machine.
2//!
3//! Single owner of the `{acquire, refresh, refresh_failed, lost_race,
4//! release}` transitions. Centralises the side-effects that must
5//! happen together so the `WriteGate` lease state and the
6//! `AuditLogger` lease/* records can never drift out of sync.
7//!
8//! Before this module those transitions lived inline in
9//! `lease_loop::spawn_refresh_thread` and in
10//! `handlers_admin::handle_admin_failover_promote`, with each
11//! caller manually pairing `write_gate.set_lease_state(...)` and
12//! `audit_log.record("lease/...")`. Four call sites, four chances
13//! for drift.
14//!
15//! Test surface:
16//!   * Construct with stand-alone `WriteGate` + `AuditLogger`.
17//!   * Inject a `MarkDraining` callback (production wires it to
18//!     `Lifecycle::mark_draining`).
19//!   * Drive transitions; assert gate state + audit lines together.
20
21use std::path::PathBuf;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use crate::api::{RedDBError, RedDBResult};
26use crate::replication::lease::{LeaseError, LeaseStore, WriterLease};
27use crate::runtime::audit_log::{
28    AuditAuthSource, AuditEvent, AuditFieldEscaper, AuditLogger, Outcome,
29};
30use crate::runtime::write_gate::{LeaseGateState, WriteGate};
31
32/// Callback the lifecycle uses to ask the surrounding runtime to
33/// drain when the lease is lost. Production wires it to
34/// `Lifecycle::mark_draining`. Tests pass a recorder.
35pub type MarkDraining = Arc<dyn Fn() + Send + Sync>;
36pub type CurrentTerm = Arc<dyn Fn() -> u64 + Send + Sync>;
37
38/// Drives the serverless writer lease for one database key.
39///
40/// Owns the `WriterLease` snapshot, the `LeaseStore` it talks to,
41/// and the side-effect channels (`WriteGate`, `AuditLogger`,
42/// `MarkDraining`). All transitions go through methods on this
43/// struct so the gate/audit pair stays consistent.
44pub struct LeaseLifecycle {
45    store: Arc<LeaseStore>,
46    write_gate: Arc<WriteGate>,
47    audit_log: Arc<AuditLogger>,
48    mark_draining: MarkDraining,
49    current_term: CurrentTerm,
50    holder_id: String,
51    database_key: String,
52    ttl_ms: u64,
53    current: Mutex<Option<WriterLease>>,
54    transition: Mutex<()>,
55    snapshot_publication:
56        Option<Arc<crate::runtime::serverless_snapshot_publication::SnapshotPublicationStore>>,
57    initial_snapshot_path: Option<PathBuf>,
58}
59
60impl LeaseLifecycle {
61    pub fn new(
62        store: Arc<LeaseStore>,
63        write_gate: Arc<WriteGate>,
64        audit_log: Arc<AuditLogger>,
65        mark_draining: MarkDraining,
66        current_term: CurrentTerm,
67        holder_id: String,
68        database_key: String,
69        ttl_ms: u64,
70    ) -> Self {
71        write_gate.set_lease_state(LeaseGateState::NotHeld);
72        Self {
73            store,
74            write_gate,
75            audit_log,
76            mark_draining,
77            current_term,
78            holder_id,
79            database_key,
80            ttl_ms,
81            current: Mutex::new(None),
82            transition: Mutex::new(()),
83            snapshot_publication: None,
84            initial_snapshot_path: None,
85        }
86    }
87
88    pub fn with_snapshot_publication(
89        mut self,
90        publication: Arc<crate::runtime::serverless_snapshot_publication::SnapshotPublicationStore>,
91        initial_snapshot_path: impl Into<PathBuf>,
92    ) -> Self {
93        self.snapshot_publication = Some(publication);
94        self.initial_snapshot_path = Some(initial_snapshot_path.into());
95        self
96    }
97
98    pub fn holder_id(&self) -> &str {
99        &self.holder_id
100    }
101
102    pub fn database_key(&self) -> &str {
103        &self.database_key
104    }
105
106    pub fn ttl_ms(&self) -> u64 {
107        self.ttl_ms
108    }
109
110    pub(crate) fn refresh_delay(&self) -> Duration {
111        self.write_gate.lease_remaining() / 3
112    }
113
114    pub fn current_lease(&self) -> Option<WriterLease> {
115        self.current.lock().expect("poisoned lease mutex").clone()
116    }
117
118    /// Acquire the writer lease and flip the gate to `Held`. Audit
119    /// line records the outcome (ok / err) either way.
120    pub fn try_acquire(&self) -> RedDBResult<()> {
121        let _transition = self.transition.lock().expect("poisoned lease transition");
122        self.write_gate.set_lease_state(LeaseGateState::NotHeld);
123        let deadline = self.request_deadline()?;
124        let term = (self.current_term)();
125        match self.store.try_acquire_for_term(
126            &self.database_key,
127            &self.holder_id,
128            self.ttl_ms,
129            term,
130        ) {
131            Ok(lease) => {
132                if let Some(publication) = &self.snapshot_publication {
133                    let local_snapshot =
134                        self.initial_snapshot_path.as_deref().ok_or_else(|| {
135                            RedDBError::Internal(
136                                "snapshot publication configured without a local snapshot path"
137                                    .to_string(),
138                            )
139                        })?;
140                    if let Err(err) = publication.claim(&lease, local_snapshot) {
141                        let _ = self.store.release(&lease);
142                        self.audit_log.record_event(
143                            AuditEvent::builder("lease/acquire")
144                                .principal(self.holder_id.clone())
145                                .source(AuditAuthSource::System)
146                                .resource(self.database_key.clone())
147                                .outcome(Outcome::Error)
148                                .field(AuditFieldEscaper::field("error", err.to_string()))
149                                .build(),
150                        );
151                        return Err(RedDBError::ReadOnly(format!(
152                            "writer lease acquired but snapshot head claim failed: {err}"
153                        )));
154                    }
155                }
156                if Instant::now() >= deadline {
157                    self.on_refresh_lost("acquire response arrived after the local lease deadline");
158                    return Err(RedDBError::ReadOnly(
159                        "writer lease expired during acquire".into(),
160                    ));
161                }
162                *self.current.lock().expect("poisoned lease mutex") = Some(lease.clone());
163                self.write_gate.grant_lease_until(deadline);
164                self.audit_log.record_event(
165                    AuditEvent::builder("lease/acquire")
166                        .principal(self.holder_id.clone())
167                        .source(AuditAuthSource::System)
168                        .resource(self.database_key.clone())
169                        .outcome(Outcome::Success)
170                        .field(AuditFieldEscaper::field(
171                            "generation",
172                            lease.generation as i64,
173                        ))
174                        .field(AuditFieldEscaper::field("term", lease.term as i64))
175                        .field(AuditFieldEscaper::field("ttl_ms", self.ttl_ms))
176                        .build(),
177                );
178                Ok(())
179            }
180            Err(err) => {
181                self.audit_log.record_event(
182                    AuditEvent::builder("lease/acquire")
183                        .principal(self.holder_id.clone())
184                        .source(AuditAuthSource::System)
185                        .resource(self.database_key.clone())
186                        .outcome(Outcome::Error)
187                        .field(AuditFieldEscaper::field("error", err.to_string()))
188                        .build(),
189                );
190                Err(RedDBError::Internal(format!("acquire writer lease: {err}")))
191            }
192        }
193    }
194
195    /// Publish a checkpoint through the generation-fenced immutable-snapshot
196    /// protocol. Returns `Ok(false)` for lease-only lifecycles that have no
197    /// publication store attached.
198    pub fn publish_snapshot(
199        &self,
200        prepare_snapshot: impl FnOnce() -> RedDBResult<reddb_file::ServerlessWriterLeaseTempFile>,
201    ) -> RedDBResult<bool> {
202        let Some(publication) = &self.snapshot_publication else {
203            return Ok(false);
204        };
205        let _transition = self.transition.lock().expect("poisoned lease transition");
206        if self.write_gate.lease_state() != LeaseGateState::Held {
207            self.on_refresh_lost("local writer lease expired before snapshot publication");
208            return Err(RedDBError::ReadOnly(
209                "writer lease expired before snapshot publication".to_string(),
210            ));
211        }
212        let lease = self
213            .current
214            .lock()
215            .expect("poisoned lease mutex")
216            .clone()
217            .ok_or_else(|| {
218                RedDBError::ReadOnly(
219                    "snapshot publication requires an acquired writer lease".to_string(),
220                )
221            })?;
222        let local_snapshot = prepare_snapshot()?;
223        if let Err(err) = publication.publish(&lease, local_snapshot.path()) {
224            self.on_refresh_lost(&err);
225            return Err(RedDBError::ReadOnly(format!(
226                "writer snapshot publication lost its fence: {err}"
227            )));
228        }
229        Ok(true)
230    }
231
232    /// Refresh the held lease. On success, updates the in-memory
233    /// snapshot and returns `Ok(())`. On any backend error, flips
234    /// the gate to `NotHeld`, audits `lease/lost`, asks the runtime
235    /// to drain, and returns `Err`.
236    ///
237    /// The refresh thread should treat `Err` as terminal and exit
238    /// — re-acquiring after a loss could race a freshly promoted
239    /// writer.
240    pub fn refresh(&self) -> RedDBResult<()> {
241        let _transition = self.transition.lock().expect("poisoned lease transition");
242        let deadline = self.request_deadline()?;
243        let snapshot = match self.current.lock().expect("poisoned lease mutex").clone() {
244            Some(lease) => lease,
245            None => {
246                return Err(RedDBError::Internal(
247                    "refresh called without an acquired lease".to_string(),
248                ));
249            }
250        };
251        if self.write_gate.lease_state() != LeaseGateState::Held {
252            self.on_refresh_lost("local writer lease deadline expired");
253            return Err(RedDBError::ReadOnly(
254                "writer lease expired before refresh".into(),
255            ));
256        }
257        let term = (self.current_term)();
258        match self.store.refresh_for_term(&snapshot, self.ttl_ms, term) {
259            Ok(updated) => {
260                if Instant::now() >= deadline {
261                    self.on_refresh_lost("refresh response arrived after the local lease deadline");
262                    return Err(RedDBError::ReadOnly(
263                        "writer lease expired during refresh".into(),
264                    ));
265                }
266                *self.current.lock().expect("poisoned lease mutex") = Some(updated);
267                self.write_gate.grant_lease_until(deadline);
268                Ok(())
269            }
270            Err(err) => {
271                self.on_refresh_lost(err);
272                Err(RedDBError::Internal("writer lease lost".to_string()))
273            }
274        }
275    }
276
277    /// Release the held lease and flip the gate to `NotHeld`. Best-
278    /// effort: a backend failure is logged but does not propagate
279    /// — shutdown should not block on a remote release.
280    pub fn release(&self) -> RedDBResult<()> {
281        let _transition = self.transition.lock().expect("poisoned lease transition");
282        // Revoke local admission before a potentially slow backend release.
283        self.write_gate.set_lease_state(LeaseGateState::NotHeld);
284        let snapshot = match self.current.lock().expect("poisoned lease mutex").take() {
285            Some(lease) => lease,
286            None => {
287                // Idempotent: already released. Make sure the gate is
288                // closed in case a previous transition didn't run.
289                self.write_gate.set_lease_state(LeaseGateState::NotHeld);
290                return Ok(());
291            }
292        };
293        let result = self.store.release(&snapshot);
294        self.write_gate.set_lease_state(LeaseGateState::NotHeld);
295        match result {
296            Ok(()) => {
297                self.audit_log.record_event(
298                    AuditEvent::builder("lease/release")
299                        .principal(self.holder_id.clone())
300                        .source(AuditAuthSource::System)
301                        .resource(self.database_key.clone())
302                        .outcome(Outcome::Success)
303                        .build(),
304                );
305                Ok(())
306            }
307            Err(err) => {
308                self.audit_log.record_event(
309                    AuditEvent::builder("lease/release")
310                        .principal(self.holder_id.clone())
311                        .source(AuditAuthSource::System)
312                        .resource(self.database_key.clone())
313                        .outcome(Outcome::Error)
314                        .field(AuditFieldEscaper::field("error", err.to_string()))
315                        .build(),
316                );
317                tracing::warn!(
318                    target: "reddb::serverless::lease",
319                    error = %err,
320                    "lease release on shutdown failed"
321                );
322                Ok(())
323            }
324        }
325    }
326
327    fn request_deadline(&self) -> RedDBResult<Instant> {
328        // Charge all backend latency against the lease, including a delayed
329        // response; using response time would incorrectly extend ownership.
330        // The backend expiry is truncated to whole wall-clock milliseconds.
331        // Subtract its quantization so our precise clock never grants longer.
332        let ttl = self
333            .ttl_ms
334            .checked_sub(1)
335            .filter(|ttl| *ttl > 0)
336            .ok_or_else(|| {
337                RedDBError::InvalidOperation("writer lease TTL must exceed 1 ms".into())
338            })?;
339        Instant::now()
340            .checked_add(Duration::from_millis(ttl))
341            .ok_or_else(|| RedDBError::InvalidOperation("writer lease TTL is too large".into()))
342    }
343
344    fn on_refresh_lost(&self, err: impl std::fmt::Display) {
345        tracing::error!(
346            target: "reddb::serverless::lease",
347            error = %err,
348            holder = %self.holder_id,
349            database_key = %self.database_key,
350            "lease refresh failed; flipping to NotHeld + drain"
351        );
352        *self.current.lock().expect("poisoned lease mutex") = None;
353        self.write_gate.set_lease_state(LeaseGateState::NotHeld);
354        self.audit_log.record_event(
355            AuditEvent::builder("lease/lost")
356                .principal(self.holder_id.clone())
357                .source(AuditAuthSource::System)
358                .resource(self.database_key.clone())
359                .outcome(Outcome::Error)
360                .field(AuditFieldEscaper::field("error", err.to_string()))
361                .build(),
362        );
363        (self.mark_draining)();
364    }
365}
366
367/// Admin-driven failover promotion: acquire the writer lease as a
368/// stand-alone action without flipping the local gate. The instance
369/// stays a `Replica` until the operator restarts it as primary; the
370/// gate flip is deliberately left out so an unintended promotion
371/// can't accept writes mid-process.
372///
373/// Audited under `admin/failover/promote` regardless of outcome.
374pub fn admin_promote_lease(
375    store: &LeaseStore,
376    audit_log: &AuditLogger,
377    database_key: &str,
378    holder_id: &str,
379    ttl_ms: u64,
380    term: u64,
381) -> Result<WriterLease, LeaseError> {
382    match store.try_acquire_for_term(database_key, holder_id, ttl_ms, term) {
383        Ok(lease) => {
384            audit_log.record_event(
385                AuditEvent::builder("admin/failover/promote")
386                    .principal(lease.holder_id.clone())
387                    .source(AuditAuthSource::System)
388                    .resource(database_key.to_string())
389                    .outcome(Outcome::Success)
390                    .field(AuditFieldEscaper::field(
391                        "holder_id",
392                        lease.holder_id.clone(),
393                    ))
394                    .field(AuditFieldEscaper::field(
395                        "generation",
396                        lease.generation as i64,
397                    ))
398                    .field(AuditFieldEscaper::field("term", lease.term as i64))
399                    .field(AuditFieldEscaper::field("ttl_ms", ttl_ms))
400                    .build(),
401            );
402            Ok(lease)
403        }
404        Err(err) => {
405            audit_log.record_event(
406                AuditEvent::builder("admin/failover/promote")
407                    .principal(holder_id.to_string())
408                    .source(AuditAuthSource::System)
409                    .resource(database_key.to_string())
410                    .outcome(Outcome::Error)
411                    .field(AuditFieldEscaper::field("error", err.to_string()))
412                    .build(),
413            );
414            Err(err)
415        }
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::api::RedDBOptions;
423    use crate::storage::backend::LocalBackend;
424    use std::path::PathBuf;
425    use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
426
427    fn temp_prefix(tag: &str) -> PathBuf {
428        let mut p = PathBuf::from(std::env::temp_dir());
429        p.push(format!(
430            "reddb-lease-lifecycle-{tag}-{}-{}",
431            std::process::id(),
432            crate::utils::now_unix_nanos(),
433        ));
434        std::fs::create_dir_all(&p).unwrap();
435        p
436    }
437
438    fn build_lifecycle(
439        tag: &str,
440    ) -> (
441        Arc<LeaseLifecycle>,
442        Arc<WriteGate>,
443        Arc<AuditLogger>,
444        Arc<AtomicUsize>,
445        PathBuf,
446    ) {
447        let (lifecycle, write_gate, audit_log, drain_counter, _current_term, prefix) =
448            build_lifecycle_with_term(tag, 1);
449        (lifecycle, write_gate, audit_log, drain_counter, prefix)
450    }
451
452    fn build_lifecycle_with_term(
453        tag: &str,
454        initial_term: u64,
455    ) -> (
456        Arc<LeaseLifecycle>,
457        Arc<WriteGate>,
458        Arc<AuditLogger>,
459        Arc<AtomicUsize>,
460        Arc<AtomicU64>,
461        PathBuf,
462    ) {
463        let prefix = temp_prefix(tag);
464        let store = Arc::new(
465            LeaseStore::new(Arc::new(LocalBackend))
466                .with_prefix(prefix.to_string_lossy().to_string()),
467        );
468        let mut opts = RedDBOptions::default();
469        opts.read_only = false;
470        let write_gate = Arc::new(WriteGate::from_options(&opts));
471        let audit_log = Arc::new(AuditLogger::for_data_path(&prefix.join("data.rdb")));
472        let drain_counter = Arc::new(AtomicUsize::new(0));
473        let drain_counter_clone = Arc::clone(&drain_counter);
474        let mark_draining: MarkDraining = Arc::new(move || {
475            drain_counter_clone.fetch_add(1, Ordering::SeqCst);
476        });
477        let current_term_value = Arc::new(AtomicU64::new(initial_term));
478        let current_term_clone = Arc::clone(&current_term_value);
479        let current_term: CurrentTerm = Arc::new(move || current_term_clone.load(Ordering::SeqCst));
480        let lifecycle = Arc::new(LeaseLifecycle::new(
481            store,
482            Arc::clone(&write_gate),
483            Arc::clone(&audit_log),
484            mark_draining,
485            current_term,
486            "writer-1".to_string(),
487            "main".to_string(),
488            60_000,
489        ));
490        (
491            lifecycle,
492            write_gate,
493            audit_log,
494            drain_counter,
495            current_term_value,
496            prefix,
497        )
498    }
499
500    #[test]
501    fn acquire_flips_gate_to_held_and_records_audit() {
502        let (lifecycle, gate, audit, drain, prefix) = build_lifecycle("acquire");
503        assert!(lifecycle.try_acquire().is_ok());
504        assert_eq!(gate.lease_state(), LeaseGateState::Held);
505        assert!(lifecycle.current_lease().is_some());
506        assert_eq!(drain.load(Ordering::SeqCst), 0);
507        // Audit log file should contain one acquire success line.
508        assert!(audit.wait_idle(std::time::Duration::from_secs(2)));
509        let body = std::fs::read_to_string(audit.path()).unwrap();
510        assert!(body.contains("lease/acquire"));
511        assert!(body.contains("\"outcome\":\"success\""));
512        let _ = std::fs::remove_dir_all(&prefix);
513    }
514
515    #[test]
516    fn successor_acquire_cannot_leave_expired_predecessor_writable() {
517        let (mut lifecycle, gate, _audit, drain, prefix) = build_lifecycle("expiry");
518        Arc::get_mut(&mut lifecycle)
519            .expect("invariant: test owns the only lifecycle reference")
520            .ttl_ms = 1_000;
521        lifecycle
522            .try_acquire()
523            .expect("first writer acquires lease");
524        let first = lifecycle
525            .current_lease()
526            .expect("acquire publishes the current lease");
527        let remaining = first
528            .expires_at_ms
529            .saturating_sub(crate::utils::now_unix_millis());
530        std::thread::sleep(Duration::from_millis(remaining + 20));
531        let successor = lifecycle
532            .store
533            .try_acquire_for_term("main", "writer-2", 60_000, 1)
534            .expect("successor acquires expired lease");
535        assert!(successor.generation > first.generation);
536        assert!(gate
537            .check(crate::runtime::write_gate::WriteKind::Dml)
538            .is_err());
539        assert!(lifecycle.refresh().is_err());
540        assert_eq!(drain.load(Ordering::SeqCst), 1);
541        assert!(lifecycle.current_lease().is_none());
542        lifecycle
543            .store
544            .release(&successor)
545            .expect("successor lease releases cleanly");
546        let _ = std::fs::remove_dir_all(prefix);
547    }
548
549    #[test]
550    fn refresh_cannot_reopen_a_released_gate() {
551        let (lifecycle, gate, _audit, _drain, prefix) = build_lifecycle("release-race");
552        lifecycle.try_acquire().expect("writer acquires lease");
553        let worker = lifecycle.clone();
554        let refresh = std::thread::spawn(move || worker.refresh());
555        lifecycle.release().expect("lease release succeeds");
556        let _ = refresh.join().expect("refresh worker does not panic");
557        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
558        assert!(lifecycle.current_lease().is_none());
559        let _ = std::fs::remove_dir_all(prefix);
560    }
561
562    #[test]
563    fn renewal_installs_a_new_local_deadline() {
564        let (lifecycle, gate, _audit, _drain, prefix) = build_lifecycle("renewal");
565        lifecycle.try_acquire().expect("writer acquires lease");
566        gate.grant_lease_until(Instant::now() + Duration::from_millis(100));
567        lifecycle.refresh().expect("lease refresh succeeds");
568        std::thread::sleep(Duration::from_millis(120));
569        assert_eq!(gate.lease_state(), LeaseGateState::Held);
570        lifecycle.release().expect("lease release succeeds");
571        let _ = std::fs::remove_dir_all(prefix);
572    }
573
574    #[test]
575    fn minimum_configured_ttl_schedules_refresh_before_expiry() {
576        let (mut lifecycle, gate, _audit, _drain, prefix) = build_lifecycle("short-ttl");
577        Arc::get_mut(&mut lifecycle)
578            .expect("invariant: test owns the only lifecycle reference")
579            .ttl_ms = 1_000;
580        lifecycle.try_acquire().expect("writer acquires lease");
581        let delay = lifecycle.refresh_delay();
582        assert!(delay < Duration::from_millis(334));
583        assert!(delay < gate.lease_remaining());
584        // A slow acquire/refresh response must shorten the next sleep too.
585        gate.grant_lease_until(Instant::now() + Duration::from_millis(90));
586        assert!(lifecycle.refresh_delay() <= Duration::from_millis(30));
587        lifecycle.release().expect("lease release succeeds");
588        let _ = std::fs::remove_dir_all(prefix);
589    }
590
591    #[test]
592    fn local_deadline_accounts_for_backend_timestamp_quantization() {
593        let (mut lifecycle, _gate, _audit, _drain, prefix) = build_lifecycle("quantization");
594        Arc::get_mut(&mut lifecycle)
595            .expect("invariant: test owns the only lifecycle reference")
596            .ttl_ms = 1;
597        assert!(lifecycle.request_deadline().is_err());
598        Arc::get_mut(&mut lifecycle)
599            .expect("invariant: test owns the only lifecycle reference")
600            .ttl_ms = 1_000;
601        let deadline = lifecycle
602            .request_deadline()
603            .expect("one-second TTL has a conservative deadline");
604        assert!(deadline.saturating_duration_since(Instant::now()) <= Duration::from_millis(999));
605        let _ = std::fs::remove_dir_all(prefix);
606    }
607
608    #[test]
609    fn delayed_acquire_response_never_opens_admission() {
610        let (mut lifecycle, gate, _audit, _drain, prefix) = build_lifecycle("delayed-acquire");
611        let lifecycle_mut = Arc::get_mut(&mut lifecycle)
612            .expect("invariant: test owns the only lifecycle reference");
613        lifecycle_mut.ttl_ms = 2;
614        lifecycle_mut.current_term = Arc::new(|| {
615            std::thread::sleep(Duration::from_millis(5));
616            1
617        });
618        assert!(lifecycle.try_acquire().is_err());
619        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
620        assert!(lifecycle.current_lease().is_none());
621        let _ = std::fs::remove_dir_all(prefix);
622    }
623
624    #[test]
625    fn acquire_stamps_current_replication_term() {
626        let (lifecycle, _gate, _audit, _drain, _term, prefix) =
627            build_lifecycle_with_term("acquire-term", 7);
628        lifecycle.try_acquire().unwrap();
629        assert_eq!(lifecycle.current_lease().unwrap().term, 7);
630        let _ = std::fs::remove_dir_all(&prefix);
631    }
632
633    #[test]
634    fn release_flips_gate_to_not_held_and_clears_inner_state() {
635        let (lifecycle, gate, audit, _drain, prefix) = build_lifecycle("release");
636        lifecycle.try_acquire().unwrap();
637        assert!(lifecycle.release().is_ok());
638        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
639        assert!(lifecycle.current_lease().is_none());
640        assert!(audit.wait_idle(std::time::Duration::from_secs(2)));
641        let body = std::fs::read_to_string(audit.path()).unwrap();
642        assert!(body.contains("lease/release"));
643        let _ = std::fs::remove_dir_all(&prefix);
644    }
645
646    #[test]
647    fn release_is_idempotent_when_no_lease_held() {
648        let (lifecycle, gate, _audit, _drain, prefix) = build_lifecycle("release-idem");
649        // No prior acquire; release should still close the gate.
650        assert!(lifecycle.release().is_ok());
651        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
652        let _ = std::fs::remove_dir_all(&prefix);
653    }
654
655    #[test]
656    fn refresh_without_acquire_keeps_gate_closed() {
657        let (lifecycle, gate, _audit, drain, prefix) = build_lifecycle("refresh-noop");
658        let err = lifecycle.refresh().unwrap_err();
659        match err {
660            RedDBError::Internal(msg) => assert!(msg.contains("without an acquired lease")),
661            other => panic!("expected Internal, got {other:?}"),
662        }
663        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
664        assert_eq!(drain.load(Ordering::SeqCst), 0);
665        let _ = std::fs::remove_dir_all(&prefix);
666    }
667
668    #[test]
669    fn refresh_fences_when_current_term_advances() {
670        let (lifecycle, gate, _audit, drain, current_term, prefix) =
671            build_lifecycle_with_term("refresh-stale-term", 4);
672        lifecycle.try_acquire().unwrap();
673        current_term.store(5, Ordering::SeqCst);
674
675        let err = lifecycle.refresh().unwrap_err();
676
677        match err {
678            RedDBError::Internal(msg) => assert_eq!(msg, "writer lease lost"),
679            other => panic!("expected Internal, got {other:?}"),
680        }
681        assert_eq!(gate.lease_state(), LeaseGateState::NotHeld);
682        assert_eq!(drain.load(Ordering::SeqCst), 1);
683        assert!(lifecycle.current_lease().is_none());
684        let _ = std::fs::remove_dir_all(&prefix);
685    }
686
687    #[test]
688    fn admin_promote_lease_audits_success() {
689        let prefix = temp_prefix("admin-ok");
690        let store = LeaseStore::new(Arc::new(LocalBackend))
691            .with_prefix(prefix.to_string_lossy().to_string());
692        let audit = AuditLogger::for_data_path(&prefix.join("data.rdb"));
693        let lease = admin_promote_lease(&store, &audit, "main", "promoter-1", 30_000, 9).unwrap();
694        assert_eq!(lease.holder_id, "promoter-1");
695        assert_eq!(lease.term, 9);
696        assert!(audit.wait_idle(std::time::Duration::from_secs(2)));
697        let body = std::fs::read_to_string(audit.path()).unwrap();
698        assert!(body.contains("admin/failover/promote"));
699        assert!(body.contains("\"outcome\":\"success\""));
700        let _ = std::fs::remove_dir_all(&prefix);
701    }
702
703    #[test]
704    fn admin_promote_lease_does_not_flip_a_separate_gate() {
705        // The promote helper takes no gate; verify the documentation
706        // contract by constructing a gate and confirming nothing
707        // touches it. (Trivial, but locks the contract in the test
708        // suite so a future refactor that adds a gate-flip side
709        // effect breaks this case.)
710        let prefix = temp_prefix("admin-no-gate");
711        let store = LeaseStore::new(Arc::new(LocalBackend))
712            .with_prefix(prefix.to_string_lossy().to_string());
713        let audit = AuditLogger::for_data_path(&prefix.join("data.rdb"));
714        let mut opts = RedDBOptions::default();
715        opts.read_only = false;
716        let gate = WriteGate::from_options(&opts);
717        let _ = admin_promote_lease(&store, &audit, "main", "promoter-2", 30_000, 1).unwrap();
718        assert_eq!(gate.lease_state(), LeaseGateState::NotRequired);
719        let _ = std::fs::remove_dir_all(&prefix);
720    }
721}