Skip to main content

krabka_client_coordination/
lease.rs

1//! The lease clock and the anti-flap policy around a lease record.
2//!
3//! # The lease adds no safety
4//!
5//! The leadership epoch is the safety mechanism, and the broker enforces it.
6//! Kafka's transaction coordinator mints one producer epoch for the
7//! `transactional.id` of a role. The quorum picks the value, and every broker
8//! rejects a write that carries an older epoch. The lease adds no safety of its
9//! own. It is a liveness and anti-flap device. It tells a standby when to stop
10//! waiting for a quiet holder. A wrong lease makes a failover early or late. It
11//! never makes two members authoritative.
12//!
13//! Read the deadline of a lease as a hint and not as a guarantee. A holder past
14//! its deadline still owns the newest epoch until a challenger mints a newer
15//! one. A challenger that reads a live lease still wins the role at once if it
16//! calls `InitProducerId` anyway. The lease only stops it from trying.
17//!
18//! # Clock skew
19//!
20//! The holder and each challenger read their own clock. A skew between two
21//! clocks shifts the failover time by the size of the skew, and no more. A
22//! challenger with a fast clock sees the deadline pass early, so it challenges
23//! early. A challenger with a slow clock challenges late.
24//!
25//! Neither case breaks safety, because no clock takes part in the fence. The
26//! challenger still calls `InitProducerId`, the coordinator still mints exactly
27//! one epoch per call, and the broker still rejects every write that carries
28//! the older epoch. Skew costs one early failover, one late failover, or some
29//! epoch churn. It cannot give two members a live epoch for the same role.
30//!
31//! # Instants and extents
32//!
33//! [`LeaseConfig`] holds extents, so each field is a [`Time`]. Every deadline
34//! and every "now" value is a coordinate on the epoch-millisecond line, so each
35//! one is a plain `i64`. The two kinds never mix in one type.
36
37use std::{
38    sync::atomic::{AtomicI64, Ordering},
39    time::{Duration, SystemTime, UNIX_EPOCH},
40};
41
42use krabka_units::{Time, convert::TimeExt, secs};
43
44use crate::{
45    error::CoordinationError,
46    record::{self, FencingToken, MemberId},
47};
48
49/// The default extent of a lease.
50pub const DEFAULT_LEASE_DURATION: Time = secs(30);
51
52/// The default gap between two renewals by the holder.
53pub const DEFAULT_RENEW_INTERVAL: Time = secs(10);
54
55/// The default extra delay that one rank of succession adds.
56pub const DEFAULT_CHALLENGE_STAGGER: Time = secs(5);
57
58/// The renew interval that the documentation recommends, as a part of the lease
59/// duration.
60const RECOMMENDED_RENEW_FRACTION: f64 = 3.0;
61
62/// The lease timings of one role.
63///
64/// The three extents are independent of the record layout, so one process can
65/// hold several roles with different timings. Build a value with
66/// [`LeaseConfig::new`], which rejects a set of extents that cannot work, or
67/// take the defaults with `LeaseConfig::default`.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct LeaseConfig {
70    duration: Time,
71    renew_interval: Time,
72    challenge_stagger: Time,
73}
74
75impl Default for LeaseConfig {
76    fn default() -> Self {
77        Self {
78            duration: DEFAULT_LEASE_DURATION,
79            renew_interval: DEFAULT_RENEW_INTERVAL,
80            challenge_stagger: DEFAULT_CHALLENGE_STAGGER,
81        }
82    }
83}
84
85impl LeaseConfig {
86    /// Builds a lease policy from three extents.
87    ///
88    /// A caller should set `renew_interval` to at most a third of `duration`.
89    /// The holder then keeps two more attempts before the deadline. The
90    /// constructor accepts a larger value and does not reject it. Ask
91    /// [`LeaseConfig::renews_with_margin`] which side of that bound a value is
92    /// on.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`CoordinationError::InvalidConfig`] when `duration`,
97    /// `renew_interval`, or `challenge_stagger` is zero, negative, infinite, or
98    /// `NaN`. Returns the same error when `renew_interval` is equal to or
99    /// larger than `duration`, because the holder then never renews in time.
100    pub fn new(
101        duration: Time,
102        renew_interval: Time,
103        challenge_stagger: Time,
104    ) -> Result<Self, CoordinationError> {
105        check_extent("lease duration", duration)?;
106        check_extent("renew interval", renew_interval)?;
107        check_extent("challenge stagger", challenge_stagger)?;
108        if renew_interval >= duration {
109            return Err(CoordinationError::InvalidConfig(format!(
110                "renew interval of {} ms is not shorter than the lease duration of {} ms",
111                renew_interval.millis_i64(),
112                duration.millis_i64()
113            )));
114        }
115        Ok(Self {
116            duration,
117            renew_interval,
118            challenge_stagger,
119        })
120    }
121
122    /// The extent of a lease that a member takes now.
123    #[must_use]
124    pub const fn duration(&self) -> Time {
125        self.duration
126    }
127
128    /// The gap between two renewals by the holder.
129    #[must_use]
130    pub const fn renew_interval(&self) -> Time {
131        self.renew_interval
132    }
133
134    /// The extra delay that one rank of succession adds.
135    #[must_use]
136    pub const fn challenge_stagger(&self) -> Time {
137        self.challenge_stagger
138    }
139
140    /// Reports whether the renew interval leaves the holder two spare attempts.
141    ///
142    /// The renew interval that this crate recommends is at most a third of the
143    /// lease duration. A configuration outside that bound still works, and the
144    /// holder then loses the role after fewer missed renewals.
145    #[must_use]
146    pub fn renews_with_margin(&self) -> bool {
147        self.renew_interval.secs_f64() * RECOMMENDED_RENEW_FRACTION <= self.duration.secs_f64()
148    }
149
150    /// The extra delay in milliseconds that a challenger of rank `rank` takes.
151    ///
152    /// The value saturates at [`i64::MAX`], so a very large rank does not wrap
153    /// the instant that a caller adds it to.
154    #[must_use]
155    pub fn challenge_delay_millis(&self, rank: usize) -> i64 {
156        let rank = i64::try_from(rank).unwrap_or(i64::MAX);
157        rank.saturating_mul(self.challenge_stagger.millis_i64())
158    }
159
160    /// The lease record that a member writes after it mints `token`.
161    ///
162    /// A renewal writes the same record with the same token and a later
163    /// `now_millis`, so this one function covers a first grant and a renewal.
164    #[must_use]
165    pub fn grant(&self, member: MemberId, token: FencingToken, now_millis: i64) -> record::Lease {
166        record::Lease {
167            member,
168            token,
169            granted_at: now_millis,
170            deadline: now_millis.saturating_add(self.duration.millis_i64()),
171        }
172    }
173
174    /// The clock questions that this policy answers about `lease`.
175    #[must_use]
176    pub const fn timing<'lease>(&self, lease: &'lease record::Lease) -> LeaseTiming<'lease> {
177        LeaseTiming {
178            lease,
179            config: *self,
180        }
181    }
182}
183
184/// Rejects an extent that is not a positive, finite length of time.
185fn check_extent(field: &str, value: Time) -> Result<(), CoordinationError> {
186    let seconds = value.secs_f64();
187    if !seconds.is_finite() || seconds <= 0.0 {
188        return Err(CoordinationError::InvalidConfig(format!(
189            "{field} must be a positive finite extent, got {seconds} s"
190        )));
191    }
192    Ok(())
193}
194
195/// The lease clock of one lease record under one policy.
196///
197/// Every method takes and returns epoch milliseconds, because a deadline is a
198/// coordinate and not an extent. [`LeaseTiming::remaining_at`] is the one
199/// exception, and it returns the extent that is left.
200#[derive(Debug, Clone, Copy)]
201pub struct LeaseTiming<'lease> {
202    lease: &'lease record::Lease,
203    config: LeaseConfig,
204}
205
206impl<'lease> LeaseTiming<'lease> {
207    /// Binds a lease record to a policy.
208    #[must_use]
209    pub const fn new(lease: &'lease record::Lease, config: LeaseConfig) -> Self {
210        Self { lease, config }
211    }
212
213    /// The lease record behind this clock.
214    #[must_use]
215    pub const fn lease(&self) -> &'lease record::Lease {
216        self.lease
217    }
218
219    /// The instant the lease expires, in milliseconds since the Unix epoch.
220    #[must_use]
221    pub const fn expires_at_millis(&self) -> i64 {
222        self.lease.deadline
223    }
224
225    /// Reports whether the lease is live at `now_millis`.
226    ///
227    /// The lease is live before its deadline and expired from the deadline on.
228    /// The rank 0 challenger may challenge at exactly the deadline, so the two
229    /// tests leave no gap and no overlap.
230    #[must_use]
231    pub const fn is_live_at(&self, now_millis: i64) -> bool {
232        now_millis < self.lease.deadline
233    }
234
235    /// The extent that is left before the deadline, and zero after it.
236    #[must_use]
237    pub fn remaining_at(&self, now_millis: i64) -> Time {
238        let left = self.lease.deadline.saturating_sub(now_millis).max(0);
239        Time::from_millis(left)
240    }
241
242    /// The instant at which the holder should write its next renewal.
243    ///
244    /// The value is the grant instant plus the renew interval. It never passes
245    /// the deadline, so a holder that follows it always writes before it loses
246    /// the lease.
247    #[must_use]
248    pub fn renew_at_millis(&self) -> i64 {
249        self.lease
250            .granted_at
251            .saturating_add(self.config.renew_interval().millis_i64())
252            .min(self.lease.deadline)
253    }
254
255    /// Reports whether the holder should write a renewal at `now_millis`.
256    #[must_use]
257    pub fn renew_due_at(&self, now_millis: i64) -> bool {
258        now_millis >= self.renew_at_millis()
259    }
260
261    /// The instant at which a challenger of rank `rank` may challenge.
262    ///
263    /// Rank 0 challenges at the deadline. Each later rank adds one challenge
264    /// stagger. The stagger saves epoch churn and gives no safety.
265    #[must_use]
266    pub fn challenge_at_millis(&self, rank: usize) -> i64 {
267        self.lease
268            .deadline
269            .saturating_add(self.config.challenge_delay_millis(rank))
270    }
271}
272
273/// A source of the current time.
274///
275/// The succession rules and the lease clock take an instant as a parameter, so
276/// they need no clock at all. This trait is the seam for the code around them
277/// that reads a real clock. A test supplies [`TestClock`] and drives time by
278/// hand, with no sleep.
279pub trait Clock: Send + Sync {
280    /// The current time, in milliseconds since the Unix epoch.
281    fn now_millis(&self) -> i64;
282}
283
284/// The clock of the host, read through [`SystemTime`].
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
286pub struct SystemClock;
287
288impl Clock for SystemClock {
289    fn now_millis(&self) -> i64 {
290        signed_millis(
291            SystemTime::now()
292                .duration_since(UNIX_EPOCH)
293                .map_err(|before| before.duration()),
294        )
295    }
296}
297
298/// Gives the epoch millis of a duration measured from the epoch.
299///
300/// `Ok` carries a duration after the epoch and `Err` carries the distance a
301/// host clock sits before it. A host clock set before 1970 gives a negative
302/// instant, and the sign is what keeps the ordering of two instants right.
303/// The function saturates rather than wrapping, because a lease deadline that
304/// wrapped would read as live.
305fn signed_millis(since_epoch: Result<Duration, Duration>) -> i64 {
306    match since_epoch {
307        Ok(after) => i64::try_from(after.as_millis()).unwrap_or(i64::MAX),
308        Err(before) => -i64::try_from(before.as_millis()).unwrap_or(i64::MAX),
309    }
310}
311
312/// A clock that a test moves by hand.
313///
314/// The clock takes `&self` for every operation, so a test shares one clock
315/// between the holder and the challengers and moves them all together.
316#[derive(Debug)]
317pub struct TestClock {
318    now_millis: AtomicI64,
319}
320
321impl TestClock {
322    /// Builds a clock that reads `now_millis`.
323    #[must_use]
324    pub const fn new(now_millis: i64) -> Self {
325        Self {
326            now_millis: AtomicI64::new(now_millis),
327        }
328    }
329
330    /// Moves the clock to `now_millis`.
331    pub fn set(&self, now_millis: i64) {
332        self.now_millis.store(now_millis, Ordering::Relaxed);
333    }
334
335    /// Moves the clock forward by `step`.
336    pub fn advance(&self, step: Time) {
337        self.now_millis
338            .fetch_add(step.millis_i64(), Ordering::Relaxed);
339    }
340}
341
342impl Clock for TestClock {
343    fn now_millis(&self) -> i64 {
344        self.now_millis.load(Ordering::Relaxed)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    /// The epoch millis of an instant keep their sign. A host clock set
351    /// before 1970 gives a negative instant, and dropping the sign would put
352    /// that instant far in the future, where an expired lease reads as live.
353    #[test]
354    fn an_instant_before_the_epoch_stays_negative() {
355        check!(signed_millis(Ok(Duration::from_secs(1_700_000_000))) == 1_700_000_000_000);
356        check!(signed_millis(Ok(Duration::ZERO)) == 0);
357        check!(signed_millis(Err(Duration::from_secs(2))) == -2_000);
358        check!(signed_millis(Err(Duration::ZERO)) == 0);
359        // A duration past the range of an i64 saturates rather than wrapping.
360        check!(signed_millis(Ok(Duration::MAX)) == i64::MAX);
361        check!(signed_millis(Err(Duration::MAX)) == -i64::MAX);
362    }
363
364    use assert2::{assert, check};
365    use krabka_units::{millis, secs};
366
367    use super::*;
368
369    fn member(name: &str) -> MemberId {
370        MemberId::new(name).expect("the member id is well formed")
371    }
372
373    fn token() -> FencingToken {
374        FencingToken::new(7, 3).expect("the token is not negative")
375    }
376
377    fn lease_at(granted_at: i64, deadline: i64) -> record::Lease {
378        record::Lease {
379            member: member("node-1"),
380            token: token(),
381            granted_at,
382            deadline,
383        }
384    }
385
386    #[test]
387    fn default_config_holds_the_documented_extents() {
388        let config = LeaseConfig::default();
389
390        check!(config.duration() == secs(30));
391        check!(config.renew_interval() == secs(10));
392        check!(config.challenge_stagger() == secs(5));
393        check!(config.renews_with_margin());
394    }
395
396    #[test]
397    fn new_accepts_the_default_extents() {
398        let built = LeaseConfig::new(secs(30), secs(10), secs(5));
399
400        assert!(let Ok(config) = built);
401        check!(config == LeaseConfig::default());
402    }
403
404    #[test]
405    fn new_rejects_extents_that_cannot_work() {
406        let cases = [
407            ("zero duration", secs(0), secs(1), secs(1)),
408            ("zero renew interval", secs(30), secs(0), secs(5)),
409            ("zero stagger", secs(30), secs(10), secs(0)),
410            ("negative duration", Time::from_millis(-1), secs(1), secs(1)),
411            (
412                "negative renew interval",
413                secs(30),
414                Time::from_millis(-1),
415                secs(5),
416            ),
417            (
418                "negative stagger",
419                secs(30),
420                secs(10),
421                Time::from_millis(-1),
422            ),
423            ("renew equals duration", secs(30), secs(30), secs(5)),
424            ("renew past duration", secs(30), secs(31), secs(5)),
425            (
426                "infinite duration",
427                Time::from_secs_f64(f64::INFINITY),
428                secs(1),
429                secs(1),
430            ),
431            (
432                "not a number",
433                Time::from_secs_f64(f64::NAN),
434                secs(1),
435                secs(1),
436            ),
437        ];
438
439        for (name, duration, renew_interval, challenge_stagger) in cases {
440            let built = LeaseConfig::new(duration, renew_interval, challenge_stagger);
441
442            assert!(let Err(error) = built, "{name} was accepted");
443            assert!(let CoordinationError::InvalidConfig(_) = error, "{name}");
444        }
445    }
446
447    #[test]
448    fn renews_with_margin_reports_the_recommended_bound() {
449        let cases = [
450            (secs(30), secs(10), true),
451            (secs(30), secs(9), true),
452            (secs(30), secs(11), false),
453            (secs(30), secs(29), false),
454        ];
455
456        for (duration, renew_interval, expected) in cases {
457            assert!(let Ok(config) = LeaseConfig::new(duration, renew_interval, secs(1)));
458
459            check!(config.renews_with_margin() == expected);
460        }
461    }
462
463    #[test]
464    fn grant_puts_the_deadline_one_duration_after_now() {
465        let config = LeaseConfig::default();
466
467        let granted = config.grant(member("node-1"), token(), 1_000);
468
469        check!(
470            granted
471                == record::Lease {
472                    member: member("node-1"),
473                    token: token(),
474                    granted_at: 1_000,
475                    deadline: 31_000,
476                }
477        );
478    }
479
480    #[test]
481    fn grant_saturates_rather_than_wrapping_the_deadline() {
482        let config = LeaseConfig::default();
483
484        let granted = config.grant(member("node-1"), token(), i64::MAX);
485
486        check!(granted.deadline == i64::MAX);
487    }
488
489    #[test]
490    fn a_lease_is_live_before_its_deadline_and_expired_from_it() {
491        let config = LeaseConfig::default();
492        let lease = lease_at(1_000, 31_000);
493        let timing = config.timing(&lease);
494
495        let cases = [
496            (0, true),
497            (1_000, true),
498            (30_999, true),
499            (31_000, false),
500            (31_001, false),
501        ];
502
503        for (now_millis, expected) in cases {
504            check!(timing.is_live_at(now_millis) == expected, "at {now_millis}");
505        }
506    }
507
508    #[test]
509    fn a_timing_binds_one_lease_to_one_policy() {
510        let config = LeaseConfig::default();
511        let lease = lease_at(1_000, 31_000);
512
513        let timing = LeaseTiming::new(&lease, config);
514
515        check!(timing.lease() == &lease);
516        check!(timing.expires_at_millis() == 31_000);
517        check!(timing.renew_at_millis() == config.timing(&lease).renew_at_millis());
518    }
519
520    #[test]
521    fn remaining_falls_to_zero_at_the_deadline() {
522        let config = LeaseConfig::default();
523        let lease = lease_at(1_000, 31_000);
524        let timing = config.timing(&lease);
525
526        check!(timing.remaining_at(1_000) == secs(30));
527        check!(timing.remaining_at(30_500) == millis(500));
528        check!(timing.remaining_at(31_000) == Time::ZERO);
529        check!(timing.remaining_at(99_000) == Time::ZERO);
530    }
531
532    #[test]
533    fn the_holder_renews_one_interval_after_the_grant() {
534        let config = LeaseConfig::default();
535        let lease = lease_at(1_000, 31_000);
536        let timing = config.timing(&lease);
537
538        check!(timing.renew_at_millis() == 11_000);
539        check!(!timing.renew_due_at(10_999));
540        check!(timing.renew_due_at(11_000));
541        check!(timing.expires_at_millis() == 31_000);
542    }
543
544    #[test]
545    fn the_renew_instant_never_passes_the_deadline() {
546        let config = LeaseConfig::default();
547        // A writer with a different policy can leave a deadline that is closer
548        // than one renew interval. The holder still renews before it expires.
549        let lease = lease_at(1_000, 3_000);
550        let timing = config.timing(&lease);
551
552        check!(timing.renew_at_millis() == 3_000);
553    }
554
555    #[test]
556    fn each_rank_adds_one_stagger_after_the_deadline() {
557        let config = LeaseConfig::default();
558        let lease = lease_at(1_000, 31_000);
559        let timing = config.timing(&lease);
560
561        let cases = [(0, 31_000), (1, 36_000), (2, 41_000), (3, 46_000)];
562
563        for (rank, expected) in cases {
564            check!(timing.challenge_at_millis(rank) == expected, "rank {rank}");
565        }
566    }
567
568    #[test]
569    fn a_huge_rank_saturates_the_challenge_instant() {
570        let config = LeaseConfig::default();
571        let lease = lease_at(1_000, 31_000);
572        let timing = config.timing(&lease);
573
574        check!(config.challenge_delay_millis(usize::MAX) == i64::MAX);
575        check!(timing.challenge_at_millis(usize::MAX) == i64::MAX);
576    }
577
578    #[test]
579    fn the_test_clock_moves_only_when_a_test_moves_it() {
580        let clock = TestClock::new(1_000);
581
582        check!(clock.now_millis() == 1_000);
583        clock.advance(secs(30));
584        check!(clock.now_millis() == 31_000);
585        clock.set(-500);
586        check!(clock.now_millis() == -500);
587    }
588
589    #[test]
590    fn the_system_clock_reads_a_plausible_wall_time() {
591        // 2020-01-01T00:00:00Z. The host clock is later than that.
592        let year_2020_millis = 1_577_836_800_000;
593
594        check!(SystemClock.now_millis() > year_2020_millis);
595    }
596}