1use 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
32pub type MarkDraining = Arc<dyn Fn() + Send + Sync>;
36pub type CurrentTerm = Arc<dyn Fn() -> u64 + Send + Sync>;
37
38pub 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 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 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 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 pub fn release(&self) -> RedDBResult<()> {
281 let _transition = self.transition.lock().expect("poisoned lease transition");
282 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 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 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
367pub 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(¤t_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 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 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 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 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}