Skip to main content

reddb_file/serverless/
lease.rs

1use super::*;
2
3use serde_json::{Map as JsonMap, Value as JsonValue};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6pub const SERVERLESS_WRITER_LEASE_DEFAULT_TERM: u64 = 1;
7static SERVERLESS_WRITER_LEASE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ServerlessWriterLease {
11    pub database_key: String,
12    pub holder_id: String,
13    pub term: u64,
14    pub generation: u64,
15    pub acquired_at_ms: u64,
16    pub expires_at_ms: u64,
17}
18
19impl ServerlessWriterLease {
20    pub fn is_expired(&self, now_ms: u64) -> bool {
21        self.expires_at_ms <= now_ms
22    }
23
24    pub fn fenced_by_term(&self, current_term: u64) -> bool {
25        self.term < current_term
26    }
27
28    pub fn fencing_token(&self) -> (u64, u64) {
29        (self.term, self.generation)
30    }
31}
32
33pub fn serverless_writer_lease_key(prefix: &str, database_key: &str) -> String {
34    format!("{prefix}{database_key}.lease.json")
35}
36
37pub fn serverless_writer_lease_temp_path(
38    kind: &str,
39    process_id: u32,
40    now_unix_nanos: u128,
41    unique: u64,
42) -> PathBuf {
43    std::env::temp_dir().join(format!(
44        "reddb-lease-{kind}-{process_id}-{now_unix_nanos}-{unique}.json"
45    ))
46}
47
48#[derive(Debug)]
49pub struct ServerlessWriterLeaseTempFile {
50    path: PathBuf,
51}
52
53impl ServerlessWriterLeaseTempFile {
54    pub fn new(kind: &str) -> Self {
55        let unique = SERVERLESS_WRITER_LEASE_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
56        Self::with_clock(kind, std::process::id(), now_unix_nanos(), unique)
57    }
58
59    pub fn with_clock(kind: &str, process_id: u32, now_unix_nanos: u128, unique: u64) -> Self {
60        Self {
61            path: serverless_writer_lease_temp_path(kind, process_id, now_unix_nanos, unique),
62        }
63    }
64
65    pub fn path(&self) -> &Path {
66        &self.path
67    }
68
69    pub fn write_bytes(&self, bytes: &[u8]) -> RdbFileResult<()> {
70        fs::write(&self.path, bytes)?;
71        Ok(())
72    }
73
74    pub fn read_bytes(&self) -> RdbFileResult<Vec<u8>> {
75        Ok(fs::read(&self.path)?)
76    }
77
78    pub fn cleanup(&self) -> RdbFileResult<()> {
79        match fs::remove_file(&self.path) {
80            Ok(()) => Ok(()),
81            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
82            Err(err) => Err(err.into()),
83        }
84    }
85}
86
87impl Drop for ServerlessWriterLeaseTempFile {
88    fn drop(&mut self) {
89        let _ = self.cleanup();
90    }
91}
92
93fn now_unix_nanos() -> u128 {
94    std::time::SystemTime::now()
95        .duration_since(std::time::UNIX_EPOCH)
96        .unwrap_or_default()
97        .as_nanos()
98}
99
100#[cfg(test)]
101mod clock_skew_tests {
102    use super::*;
103    use crate::clock::{Clock, SimClock};
104
105    // 2024-03-10 01:30:00.000 UTC — 30 min before US/Eastern DST spring-forward
106    const SEED_MS: u64 = 1_710_033_000_000;
107    const LEASE_DURATION_MS: u64 = 5_000; // 5-second lease
108
109    fn make_lease(acquired_at_ms: u64) -> ServerlessWriterLease {
110        ServerlessWriterLease {
111            database_key: "test-db".to_string(),
112            holder_id: "holder-A".to_string(),
113            term: 1,
114            generation: 1,
115            acquired_at_ms,
116            expires_at_ms: acquired_at_ms + LEASE_DURATION_MS,
117        }
118    }
119
120    /// A holder with a clock 2 hours behind (pre-DST, hasn't sprung forward)
121    /// incorrectly thinks its lease is still live.  The coordinator — which
122    /// runs the authoritative clock — correctly sees the lease as expired, so
123    /// the single-writer invariant holds: the coordinator will not grant a new
124    /// lease while it still thinks the old one is live, and the term fence
125    /// closes the gap if the coordinator also needs to depose the holder.
126    #[test]
127    fn clock_skew_does_not_extend_lease() {
128        let skew_ms = 2 * 3_600_000u64; // 2-hour DST gap
129
130        // Coordinator clock (authoritative) starts at SEED_MS.
131        let coordinator = SimClock::from_seed(SEED_MS);
132        // Holder clock is 2 hours BEHIND (pre-DST, hasn't sprung forward).
133        let holder = SimClock::from_seed(SEED_MS - skew_ms);
134
135        // The coordinator grants the lease; expiry is based on the
136        // authoritative wall clock.  This is what gets persisted.
137        let lease = make_lease(coordinator.now_unix_millis());
138
139        // 6 seconds pass on both clocks.
140        coordinator.advance_ms(6_000);
141        holder.advance_ms(6_000);
142
143        // Coordinator: lease has expired (6 s > 5 s after expiry).
144        assert!(
145            lease.is_expired(coordinator.now_unix_millis()),
146            "coordinator must see the lease as expired after 6 s"
147        );
148
149        // Holder's clock is still ~2 h before the expiry timestamp, so the
150        // holder's own check incorrectly reports the lease as live.
151        // expires_at = SEED_MS + 5_000; holder now = SEED_MS - skew_ms + 6_000
152        // SEED_MS + 5_000 > SEED_MS - skew_ms + 6_000 because skew_ms >> 5_000
153        assert!(
154            !lease.is_expired(holder.now_unix_millis()),
155            "skewed holder clock incorrectly sees lease as live (the hazard clock-skew creates)"
156        );
157
158        // The coordinator's authoritative check always supersedes the holder's
159        // self-assessment — this is the fencing guarantee.
160        assert!(
161            lease.is_expired(coordinator.now_unix_millis()),
162            "authoritative coordinator check must override holder self-assessment"
163        );
164    }
165
166    /// A deposed primary (lower term) is fenced regardless of what the clock says.
167    #[test]
168    fn deposed_primary_fails_closed_via_term_fence() {
169        let clock = SimClock::from_seed(SEED_MS);
170
171        // Term-1 primary acquires a long-lived lease; time has not advanced.
172        let lease = make_lease(clock.now_unix_millis());
173
174        // Coordinator elects a new primary — term advances to 2.
175        let new_term = 2u64;
176
177        // Old primary is still within its lease window by time alone.
178        assert!(
179            !lease.is_expired(clock.now_unix_millis()),
180            "lease is still live by time, but must be fenced by term"
181        );
182        assert!(
183            lease.fenced_by_term(new_term),
184            "deposed primary must be fenced by the higher term"
185        );
186
187        // A clock-skewed old primary also cannot bypass the term fence.
188        let old_primary_clock = SimClock::from_seed(SEED_MS - 3_600_000);
189        drop(old_primary_clock); // only needed to prove skew is irrelevant
190        assert!(
191            lease.fenced_by_term(new_term),
192            "term fence must hold regardless of old primary clock skew"
193        );
194    }
195
196    /// Same seed + same advance sequence always produces the same timestamps.
197    #[test]
198    fn simclock_is_reproducible_by_seed() {
199        let clock_a = SimClock::from_seed(SEED_MS);
200        let clock_b = SimClock::from_seed(SEED_MS);
201
202        // Simulate DST spring-forward: both clocks skip an hour.
203        clock_a.advance_ms(3_600_000);
204        clock_b.advance_ms(3_600_000);
205
206        assert_eq!(
207            clock_a.now_unix_millis(),
208            clock_b.now_unix_millis(),
209            "SimClock must be deterministic for the same seed and advance sequence"
210        );
211
212        // Both clocks agree the 5-second lease is expired after an hour.
213        let lease = make_lease(SEED_MS);
214        assert!(lease.is_expired(clock_a.now_unix_millis()));
215        assert!(lease.is_expired(clock_b.now_unix_millis()));
216    }
217
218    /// `set_ms` can model a timezone shift (jumping the UTC offset by ±1 h).
219    #[test]
220    fn simclock_set_ms_models_timezone_shift() {
221        let clock = SimClock::from_seed(SEED_MS);
222        let lease = make_lease(clock.now_unix_millis());
223
224        // Jump forward by exactly the DST gap (1 hour).
225        clock.set_ms(SEED_MS + 3_600_000);
226        assert!(
227            lease.is_expired(clock.now_unix_millis()),
228            "lease expired after timezone shift of +1 h"
229        );
230
231        // Rewind within the lease window.
232        clock.set_ms(SEED_MS + 1_000);
233        assert!(
234            !lease.is_expired(clock.now_unix_millis()),
235            "lease still live when clock is within the expiry window"
236        );
237    }
238}
239
240pub fn encode_serverless_writer_lease_json(
241    lease: &ServerlessWriterLease,
242) -> RdbFileResult<Vec<u8>> {
243    let mut object = JsonMap::new();
244    object.insert(
245        "database_key".to_string(),
246        JsonValue::String(lease.database_key.clone()),
247    );
248    object.insert(
249        "holder_id".to_string(),
250        JsonValue::String(lease.holder_id.clone()),
251    );
252    object.insert("term".to_string(), JsonValue::Number(lease.term.into()));
253    object.insert(
254        "generation".to_string(),
255        JsonValue::Number(lease.generation.into()),
256    );
257    object.insert(
258        "acquired_at_ms".to_string(),
259        JsonValue::Number(lease.acquired_at_ms.into()),
260    );
261    object.insert(
262        "expires_at_ms".to_string(),
263        JsonValue::Number(lease.expires_at_ms.into()),
264    );
265    serde_json::to_vec(&JsonValue::Object(object))
266        .map_err(|err| RdbFileError::InvalidOperation(format!("encode writer lease: {err}")))
267}
268
269pub fn decode_serverless_writer_lease_json(bytes: &[u8]) -> RdbFileResult<ServerlessWriterLease> {
270    let value: JsonValue = serde_json::from_slice(bytes).map_err(|err| {
271        RdbFileError::InvalidOperation(format!("decode writer lease json: {err}"))
272    })?;
273    let object = value
274        .as_object()
275        .ok_or_else(|| RdbFileError::InvalidOperation("lease json is not an object".into()))?;
276    Ok(ServerlessWriterLease {
277        database_key: required_string(object, "database_key")?,
278        holder_id: required_string(object, "holder_id")?,
279        term: object
280            .get("term")
281            .and_then(JsonValue::as_u64)
282            .unwrap_or(SERVERLESS_WRITER_LEASE_DEFAULT_TERM),
283        generation: required_u64(object, "generation")?,
284        acquired_at_ms: required_u64(object, "acquired_at_ms")?,
285        expires_at_ms: required_u64(object, "expires_at_ms")?,
286    })
287}
288
289fn required_string(object: &JsonMap<String, JsonValue>, field: &str) -> RdbFileResult<String> {
290    object
291        .get(field)
292        .and_then(JsonValue::as_str)
293        .map(ToString::to_string)
294        .ok_or_else(|| RdbFileError::InvalidOperation(format!("missing {field}")))
295}
296
297fn required_u64(object: &JsonMap<String, JsonValue>, field: &str) -> RdbFileResult<u64> {
298    object
299        .get(field)
300        .and_then(JsonValue::as_u64)
301        .ok_or_else(|| RdbFileError::InvalidOperation(format!("missing {field}")))
302}