1use 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
49pub const DEFAULT_LEASE_DURATION: Time = secs(30);
51
52pub const DEFAULT_RENEW_INTERVAL: Time = secs(10);
54
55pub const DEFAULT_CHALLENGE_STAGGER: Time = secs(5);
57
58const RECOMMENDED_RENEW_FRACTION: f64 = 3.0;
61
62#[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 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 #[must_use]
124 pub const fn duration(&self) -> Time {
125 self.duration
126 }
127
128 #[must_use]
130 pub const fn renew_interval(&self) -> Time {
131 self.renew_interval
132 }
133
134 #[must_use]
136 pub const fn challenge_stagger(&self) -> Time {
137 self.challenge_stagger
138 }
139
140 #[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 #[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 #[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 #[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
184fn 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#[derive(Debug, Clone, Copy)]
201pub struct LeaseTiming<'lease> {
202 lease: &'lease record::Lease,
203 config: LeaseConfig,
204}
205
206impl<'lease> LeaseTiming<'lease> {
207 #[must_use]
209 pub const fn new(lease: &'lease record::Lease, config: LeaseConfig) -> Self {
210 Self { lease, config }
211 }
212
213 #[must_use]
215 pub const fn lease(&self) -> &'lease record::Lease {
216 self.lease
217 }
218
219 #[must_use]
221 pub const fn expires_at_millis(&self) -> i64 {
222 self.lease.deadline
223 }
224
225 #[must_use]
231 pub const fn is_live_at(&self, now_millis: i64) -> bool {
232 now_millis < self.lease.deadline
233 }
234
235 #[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 #[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 #[must_use]
257 pub fn renew_due_at(&self, now_millis: i64) -> bool {
258 now_millis >= self.renew_at_millis()
259 }
260
261 #[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
273pub trait Clock: Send + Sync {
280 fn now_millis(&self) -> i64;
282}
283
284#[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
298fn 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#[derive(Debug)]
317pub struct TestClock {
318 now_millis: AtomicI64,
319}
320
321impl TestClock {
322 #[must_use]
324 pub const fn new(now_millis: i64) -> Self {
325 Self {
326 now_millis: AtomicI64::new(now_millis),
327 }
328 }
329
330 pub fn set(&self, now_millis: i64) {
332 self.now_millis.store(now_millis, Ordering::Relaxed);
333 }
334
335 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 #[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 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 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 let year_2020_millis = 1_577_836_800_000;
593
594 check!(SystemClock.now_millis() > year_2020_millis);
595 }
596}