Skip to main content

reddb_server/runtime/
lease_loop.rs

1//! Writer-lease boot path with optional serverless snapshot hydration.
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: Option<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    // A primary/replica or cluster lease fences ownership of its local store.
81    // Only snapshot-backed profiles may replace that store from a remote head.
82    let snapshot_publication = match options.storage_profile.deploy_profile {
83        crate::storage::DeployProfile::Embedded | crate::storage::DeployProfile::Serverless => {
84            Some(Arc::new(SnapshotPublicationStore::new(
85                backend.clone(),
86                database_key.clone(),
87            )))
88        }
89        crate::storage::DeployProfile::PrimaryReplica | crate::storage::DeployProfile::Cluster => {
90            None
91        }
92    };
93    options.remote_bootstrap_key = snapshot_publication
94        .as_ref()
95        .map(|publication| publication.observe_for_boot())
96        .transpose()
97        .map_err(|err| RedDBError::Internal(err.to_string()))?
98        .flatten();
99    if options.remote_bootstrap_key.is_some()
100        && options.storage_profile.deploy_profile != crate::storage::DeployProfile::Embedded
101    {
102        // Operational caches have WAL and metadata siblings. Hydrate into an
103        // isolated boot directory so an old local WAL cannot replay over the
104        // authoritative snapshot. Reuse the existing ephemeral cleanup owner.
105        let cache = crate::api::RedDBOptions::in_memory();
106        options.data_path = cache.data_path;
107        options.metadata.insert(
108            crate::api::EPHEMERAL_RUNTIME_METADATA_KEY.into(),
109            "true".into(),
110        );
111    }
112    let initial_snapshot_required =
113        snapshot_publication.is_some() && options.remote_bootstrap_key.is_none();
114    Ok(Some(LeaseBootPlan {
115        store: Arc::new(LeaseStore::new(backend).with_prefix(prefix)),
116        snapshot_publication,
117        holder_id: lease_holder_id(),
118        database_key,
119        ttl_ms: lease_ttl_secs() * 1000,
120        initial_snapshot_required,
121    }))
122}
123
124/// Try to start the writer-lease lifecycle if the operator opted in.
125/// Returns `Ok(())` when:
126///   * `RED_LEASE_REQUIRED` is unset / false, or
127///   * the lease was acquired and the refresh thread is running.
128///
129/// Returns `Err` when the operator asked for a lease and we couldn't
130/// get one — the caller should refuse to serve in that case.
131pub fn start_lease_loop_if_required(runtime: &RedDBRuntime) -> RedDBResult<()> {
132    let mut options = runtime.db().options().clone();
133    let plan = prepare_lease_loop_if_required(&mut options)?;
134    if options.remote_bootstrap_key.is_some() {
135        return Err(RedDBError::InvalidOperation(
136            "serverless snapshot head must be observed before runtime hydration; use the prepared lease boot path"
137                .to_string(),
138        ));
139    }
140    start_prepared_lease_loop(runtime, plan)
141}
142
143pub fn start_prepared_lease_loop(
144    runtime: &RedDBRuntime,
145    plan: Option<LeaseBootPlan>,
146) -> RedDBResult<()> {
147    let Some(plan) = plan else {
148        return Ok(());
149    };
150    // Existing heads are claimed without re-uploading or re-checkpointing the
151    // hydrated database. Only migration to the first head needs a payload.
152    let local_snapshot = if plan.initial_snapshot_required {
153        Some(runtime.capture_serverless_snapshot()?)
154    } else {
155        None
156    };
157    let runtime_for_drain = Arc::downgrade(&runtime.inner);
158    let mark_draining: MarkDraining = Arc::new(move || {
159        if let Some(inner) = runtime_for_drain.upgrade() {
160            inner.lifecycle.mark_draining();
161        }
162    });
163    let term = runtime.current_replication_term();
164    let current_term = Arc::new(move || term);
165    let mut lifecycle = LeaseLifecycle::new(
166        plan.store,
167        runtime.write_gate_arc(),
168        runtime.audit_log_arc(),
169        mark_draining,
170        current_term,
171        plan.holder_id,
172        plan.database_key,
173        plan.ttl_ms,
174    );
175    if let Some(publication) = plan.snapshot_publication {
176        let database = runtime.db();
177        let snapshot_path = local_snapshot
178            .as_ref()
179            .map(|snapshot| snapshot.path())
180            .or_else(|| database.path())
181            .ok_or_else(|| {
182                RedDBError::InvalidOperation(
183                    "serverless boot requires a persistent local cache".into(),
184                )
185            })?;
186        lifecycle = lifecycle.with_snapshot_publication(publication, snapshot_path);
187    }
188    let lifecycle = Arc::new(lifecycle);
189    lifecycle.try_acquire()?;
190
191    // Stash the lifecycle on the runtime so admin handlers and the
192    // refresh thread share one instance. The OnceLock guarantees
193    // idempotency — re-entering the boot path (tests, double-init)
194    // returns Err and we drop the duplicate.
195    let lifecycle_for_runtime = Arc::clone(&lifecycle);
196    let _ = runtime.set_lease_lifecycle(lifecycle_for_runtime);
197
198    spawn_refresh_thread(runtime.clone(), lifecycle);
199    Ok(())
200}
201
202fn spawn_refresh_thread(runtime: RedDBRuntime, lifecycle: Arc<LeaseLifecycle>) {
203    // Timer wheel replaces thread::sleep(interval) — wakes exactly when the
204    // next refresh is due. Zero CPU during the idle window.
205    let wheel = Arc::new(LeaseTimerWheel::new(1));
206    wheel.schedule(
207        "lease-refresh".to_string(),
208        Instant::now() + lifecycle.refresh_delay(),
209    );
210
211    let wheel_for_handler = Arc::clone(&wheel);
212    let _ = thread::Builder::new()
213        .name("reddb-lease-refresh".into())
214        .spawn(move || {
215            wheel.run_until_shutdown(move |_id| {
216                // Keep the lease through drain and final publication. Shutdown
217                // releases it only after the last snapshot; a lease-loss path
218                // terminates below when refresh fails.
219                let phase = runtime.lifecycle().phase();
220                if phase == crate::runtime::lifecycle::Phase::Stopped {
221                    let _ = lifecycle.release();
222                    return false;
223                }
224
225                if lifecycle.refresh().is_err() {
226                    return false;
227                }
228
229                // Reschedule the next refresh.
230                wheel_for_handler.schedule(
231                    "lease-refresh".to_string(),
232                    Instant::now() + lifecycle.refresh_delay(),
233                );
234                true
235            });
236        });
237}
238
239fn lease_required() -> bool {
240    std::env::var("RED_LEASE_REQUIRED")
241        .ok()
242        .map(|v| {
243            let t = v.trim();
244            t.eq_ignore_ascii_case("true") || t == "1" || t.eq_ignore_ascii_case("yes")
245        })
246        .unwrap_or(false)
247}
248
249fn lease_ttl_secs() -> u64 {
250    std::env::var("RED_LEASE_TTL_SECS")
251        .ok()
252        .and_then(|s| s.trim().parse::<u64>().ok())
253        .filter(|v| *v > 0)
254        .unwrap_or(60)
255}
256
257fn lease_holder_id() -> String {
258    if let Some(explicit) = crate::utils::env_with_file_fallback("RED_LEASE_HOLDER_ID") {
259        return explicit;
260    }
261    let host = std::env::var("HOSTNAME")
262        .or_else(|_| std::env::var("HOST"))
263        .unwrap_or_else(|_| "unknown-host".to_string());
264    format!("{}-{}", host, std::process::id())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn lease_required_parses_truthy_values() {
273        unsafe {
274            std::env::set_var("RED_LEASE_REQUIRED", "true");
275        }
276        assert!(lease_required());
277        unsafe {
278            std::env::set_var("RED_LEASE_REQUIRED", "1");
279        }
280        assert!(lease_required());
281        unsafe {
282            std::env::set_var("RED_LEASE_REQUIRED", "yes");
283        }
284        assert!(lease_required());
285        unsafe {
286            std::env::set_var("RED_LEASE_REQUIRED", "false");
287        }
288        assert!(!lease_required());
289        unsafe {
290            std::env::remove_var("RED_LEASE_REQUIRED");
291        }
292        assert!(!lease_required());
293    }
294
295    #[test]
296    fn ttl_defaults_to_60_when_unset() {
297        unsafe {
298            std::env::remove_var("RED_LEASE_TTL_SECS");
299        }
300        assert_eq!(lease_ttl_secs(), 60);
301    }
302
303    #[test]
304    fn ttl_rejects_zero_and_negative() {
305        unsafe {
306            std::env::set_var("RED_LEASE_TTL_SECS", "0");
307        }
308        assert_eq!(lease_ttl_secs(), 60);
309        unsafe {
310            std::env::set_var("RED_LEASE_TTL_SECS", "abc");
311        }
312        assert_eq!(lease_ttl_secs(), 60);
313        unsafe {
314            std::env::set_var("RED_LEASE_TTL_SECS", "30");
315        }
316        assert_eq!(lease_ttl_secs(), 30);
317        unsafe {
318            std::env::remove_var("RED_LEASE_TTL_SECS");
319        }
320    }
321
322    #[test]
323    fn holder_id_falls_back_when_no_env() {
324        unsafe {
325            std::env::remove_var("RED_LEASE_HOLDER_ID");
326        }
327        let id = lease_holder_id();
328        assert!(id.contains('-'));
329        assert!(!id.is_empty());
330    }
331
332    #[test]
333    fn holder_id_uses_explicit_when_set() {
334        unsafe {
335            std::env::set_var("RED_LEASE_HOLDER_ID", "explicit-writer-1");
336        }
337        assert_eq!(lease_holder_id(), "explicit-writer-1");
338        unsafe {
339            std::env::remove_var("RED_LEASE_HOLDER_ID");
340        }
341    }
342}