Skip to main content

reddb_server/runtime/
lease_loop.rs

1//! Serverless writer-lease boot path (PLAN.md Phase 5 / W6).
2//!
3//! Boot-time entrypoint that opts the runtime into lease-fenced
4//! writes when:
5//!   * `RED_LEASE_REQUIRED=true` (or `1`) is set, and
6//!   * a remote backend is configured (S3, FS, HTTP, …).
7//!
8//! All transitions (acquire / refresh / lost / release) are
9//! delegated to `LeaseLifecycle`; this module only owns env-var
10//! parsing, lifecycle construction, and the refresh thread.
11//!
12//! The refresh thread uses `LeaseTimerWheel` instead of a fixed
13//! `thread::sleep(ttl_ms / 3)` polling loop. The wheel wakes the
14//! thread exactly when the next refresh is due and reschedules after
15//! each successful refresh, so idle leases cost zero CPU.
16//!
17//! Env knobs:
18//!   * `RED_LEASE_REQUIRED` — `true` / `1` to enable. Default off.
19//!   * `RED_LEASE_TTL_SECS` — lease TTL in seconds. Default 60.
20//!   * `RED_LEASE_HOLDER_ID` — explicit holder id. Default
21//!     `<hostname>-<pid>`.
22//!   * `RED_LEASE_PREFIX` — backend prefix for the lease object key.
23//!     Default `leases/`.
24
25use std::sync::Arc;
26use std::thread;
27use std::time::Instant;
28
29use crate::api::{RedDBError, RedDBResult};
30use crate::replication::lease::LeaseStore;
31use crate::runtime::lease_lifecycle::{LeaseLifecycle, MarkDraining};
32use crate::runtime::lease_timer_wheel::LeaseTimerWheel;
33use crate::runtime::serverless_snapshot_publication::SnapshotPublicationStore;
34use crate::runtime::RedDBRuntime;
35
36pub struct LeaseBootPlan {
37    store: Arc<LeaseStore>,
38    snapshot_publication: Arc<SnapshotPublicationStore>,
39    holder_id: String,
40    database_key: String,
41    ttl_ms: u64,
42    initial_snapshot_required: bool,
43}
44
45/// Observe the exact snapshot head before runtime hydration. The later lease
46/// claim CASes that observed version; publication racing with boot therefore
47/// aborts this boot instead of letting it serve a stale local snapshot.
48pub fn prepare_lease_loop_if_required(
49    options: &mut crate::api::RedDBOptions,
50) -> RedDBResult<Option<LeaseBootPlan>> {
51    if !lease_required() {
52        return Ok(None);
53    }
54    if options.read_only {
55        return Err(RedDBError::InvalidOperation(
56            "serverless writer leases require a writable local cache".into(),
57        ));
58    }
59    let backend = options.remote_backend_atomic.clone().ok_or_else(|| {
60        RedDBError::Internal(
61            "RED_LEASE_REQUIRED=true but the configured backend does not support atomic CAS — \
62             use s3, fs, or http with RED_HTTP_CONDITIONAL_WRITES=true"
63                .to_string(),
64        )
65    })?;
66    let database_key = options
67        .remote_key
68        .clone()
69        .filter(|key| !key.is_empty())
70        .filter(|_| options.remote_backend.is_some())
71        .ok_or_else(|| {
72            RedDBError::InvalidOperation(
73                "serverless writer leases require a remote backend and database key".into(),
74            )
75        })?;
76    let prefix = std::env::var("RED_LEASE_PREFIX")
77        .ok()
78        .filter(|value| !value.trim().is_empty())
79        .unwrap_or_else(|| "leases/".to_string());
80    let snapshot_publication = Arc::new(SnapshotPublicationStore::new(
81        backend.clone(),
82        database_key.clone(),
83    ));
84    options.remote_bootstrap_key = snapshot_publication
85        .observe_for_boot()
86        .map_err(|err| RedDBError::Internal(err.to_string()))?;
87    if options.remote_bootstrap_key.is_some()
88        && options.storage_profile.deploy_profile != crate::storage::DeployProfile::Embedded
89    {
90        // Operational caches have WAL and metadata siblings. Hydrate into an
91        // isolated boot directory so an old local WAL cannot replay over the
92        // authoritative snapshot. Reuse the existing ephemeral cleanup owner.
93        let cache = crate::api::RedDBOptions::in_memory();
94        options.data_path = cache.data_path;
95        options.metadata.insert(
96            crate::api::EPHEMERAL_RUNTIME_METADATA_KEY.into(),
97            "true".into(),
98        );
99    }
100    Ok(Some(LeaseBootPlan {
101        store: Arc::new(LeaseStore::new(backend).with_prefix(prefix)),
102        snapshot_publication,
103        holder_id: lease_holder_id(),
104        database_key,
105        ttl_ms: lease_ttl_secs() * 1000,
106        initial_snapshot_required: options.remote_bootstrap_key.is_none(),
107    }))
108}
109
110/// Try to start the writer-lease lifecycle if the operator opted in.
111/// Returns `Ok(())` when:
112///   * `RED_LEASE_REQUIRED` is unset / false, or
113///   * the lease was acquired and the refresh thread is running.
114///
115/// Returns `Err` when the operator asked for a lease and we couldn't
116/// get one — the caller should refuse to serve in that case.
117pub fn start_lease_loop_if_required(runtime: &RedDBRuntime) -> RedDBResult<()> {
118    let mut options = runtime.db().options().clone();
119    let plan = prepare_lease_loop_if_required(&mut options)?;
120    if options.remote_bootstrap_key.is_some() {
121        return Err(RedDBError::InvalidOperation(
122            "serverless snapshot head must be observed before runtime hydration; use the prepared lease boot path"
123                .to_string(),
124        ));
125    }
126    start_prepared_lease_loop(runtime, plan)
127}
128
129pub fn start_prepared_lease_loop(
130    runtime: &RedDBRuntime,
131    plan: Option<LeaseBootPlan>,
132) -> RedDBResult<()> {
133    let Some(plan) = plan else {
134        return Ok(());
135    };
136    // Existing heads are claimed without re-uploading or re-checkpointing the
137    // hydrated database. Only migration to the first head needs a payload.
138    let local_snapshot = if plan.initial_snapshot_required {
139        Some(runtime.capture_serverless_snapshot()?)
140    } else {
141        None
142    };
143    let database = runtime.db();
144    let snapshot_path = local_snapshot
145        .as_ref()
146        .map(|snapshot| snapshot.path())
147        .or_else(|| database.path())
148        .ok_or_else(|| {
149            RedDBError::InvalidOperation("serverless boot requires a persistent local cache".into())
150        })?;
151    let runtime_for_drain = Arc::downgrade(&runtime.inner);
152    let mark_draining: MarkDraining = Arc::new(move || {
153        if let Some(inner) = runtime_for_drain.upgrade() {
154            inner.lifecycle.mark_draining();
155        }
156    });
157    let term = runtime.current_replication_term();
158    let current_term = Arc::new(move || term);
159    let lifecycle = Arc::new(
160        LeaseLifecycle::new(
161            plan.store,
162            runtime.write_gate_arc(),
163            runtime.audit_log_arc(),
164            mark_draining,
165            current_term,
166            plan.holder_id,
167            plan.database_key,
168            plan.ttl_ms,
169        )
170        .with_snapshot_publication(plan.snapshot_publication, snapshot_path),
171    );
172    lifecycle.try_acquire()?;
173
174    // Stash the lifecycle on the runtime so admin handlers and the
175    // refresh thread share one instance. The OnceLock guarantees
176    // idempotency — re-entering the boot path (tests, double-init)
177    // returns Err and we drop the duplicate.
178    let lifecycle_for_runtime = Arc::clone(&lifecycle);
179    let _ = runtime.set_lease_lifecycle(lifecycle_for_runtime);
180
181    spawn_refresh_thread(runtime.clone(), lifecycle);
182    Ok(())
183}
184
185fn spawn_refresh_thread(runtime: RedDBRuntime, lifecycle: Arc<LeaseLifecycle>) {
186    // Timer wheel replaces thread::sleep(interval) — wakes exactly when the
187    // next refresh is due. Zero CPU during the idle window.
188    let wheel = Arc::new(LeaseTimerWheel::new(1));
189    wheel.schedule(
190        "lease-refresh".to_string(),
191        Instant::now() + lifecycle.refresh_delay(),
192    );
193
194    let wheel_for_handler = Arc::clone(&wheel);
195    let _ = thread::Builder::new()
196        .name("reddb-lease-refresh".into())
197        .spawn(move || {
198            wheel.run_until_shutdown(move |_id| {
199                // Keep the lease through drain and final publication. Shutdown
200                // releases it only after the last snapshot; a lease-loss path
201                // terminates below when refresh fails.
202                let phase = runtime.lifecycle().phase();
203                if phase == crate::runtime::lifecycle::Phase::Stopped {
204                    let _ = lifecycle.release();
205                    return false;
206                }
207
208                if lifecycle.refresh().is_err() {
209                    return false;
210                }
211
212                // Reschedule the next refresh.
213                wheel_for_handler.schedule(
214                    "lease-refresh".to_string(),
215                    Instant::now() + lifecycle.refresh_delay(),
216                );
217                true
218            });
219        });
220}
221
222fn lease_required() -> bool {
223    std::env::var("RED_LEASE_REQUIRED")
224        .ok()
225        .map(|v| {
226            let t = v.trim();
227            t.eq_ignore_ascii_case("true") || t == "1" || t.eq_ignore_ascii_case("yes")
228        })
229        .unwrap_or(false)
230}
231
232fn lease_ttl_secs() -> u64 {
233    std::env::var("RED_LEASE_TTL_SECS")
234        .ok()
235        .and_then(|s| s.trim().parse::<u64>().ok())
236        .filter(|v| *v > 0)
237        .unwrap_or(60)
238}
239
240fn lease_holder_id() -> String {
241    if let Some(explicit) = crate::utils::env_with_file_fallback("RED_LEASE_HOLDER_ID") {
242        return explicit;
243    }
244    let host = std::env::var("HOSTNAME")
245        .or_else(|_| std::env::var("HOST"))
246        .unwrap_or_else(|_| "unknown-host".to_string());
247    format!("{}-{}", host, std::process::id())
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn lease_required_parses_truthy_values() {
256        unsafe {
257            std::env::set_var("RED_LEASE_REQUIRED", "true");
258        }
259        assert!(lease_required());
260        unsafe {
261            std::env::set_var("RED_LEASE_REQUIRED", "1");
262        }
263        assert!(lease_required());
264        unsafe {
265            std::env::set_var("RED_LEASE_REQUIRED", "yes");
266        }
267        assert!(lease_required());
268        unsafe {
269            std::env::set_var("RED_LEASE_REQUIRED", "false");
270        }
271        assert!(!lease_required());
272        unsafe {
273            std::env::remove_var("RED_LEASE_REQUIRED");
274        }
275        assert!(!lease_required());
276    }
277
278    #[test]
279    fn ttl_defaults_to_60_when_unset() {
280        unsafe {
281            std::env::remove_var("RED_LEASE_TTL_SECS");
282        }
283        assert_eq!(lease_ttl_secs(), 60);
284    }
285
286    #[test]
287    fn ttl_rejects_zero_and_negative() {
288        unsafe {
289            std::env::set_var("RED_LEASE_TTL_SECS", "0");
290        }
291        assert_eq!(lease_ttl_secs(), 60);
292        unsafe {
293            std::env::set_var("RED_LEASE_TTL_SECS", "abc");
294        }
295        assert_eq!(lease_ttl_secs(), 60);
296        unsafe {
297            std::env::set_var("RED_LEASE_TTL_SECS", "30");
298        }
299        assert_eq!(lease_ttl_secs(), 30);
300        unsafe {
301            std::env::remove_var("RED_LEASE_TTL_SECS");
302        }
303    }
304
305    #[test]
306    fn holder_id_falls_back_when_no_env() {
307        unsafe {
308            std::env::remove_var("RED_LEASE_HOLDER_ID");
309        }
310        let id = lease_holder_id();
311        assert!(id.contains('-'));
312        assert!(!id.is_empty());
313    }
314
315    #[test]
316    fn holder_id_uses_explicit_when_set() {
317        unsafe {
318            std::env::set_var("RED_LEASE_HOLDER_ID", "explicit-writer-1");
319        }
320        assert_eq!(lease_holder_id(), "explicit-writer-1");
321        unsafe {
322            std::env::remove_var("RED_LEASE_HOLDER_ID");
323        }
324    }
325}