Skip to main content

krabka_client_coordination/
succession.rs

1//! The succession rules of one role: ordered candidates, and no failback.
2//!
3//! # The epoch is the safety mechanism
4//!
5//! Kafka's transaction coordinator mints the leadership epoch when a member
6//! calls `InitProducerId` for the `transactional.id` of a role. The quorum
7//! picks the value, the value only grows, and every broker rejects a write that
8//! carries an older one. That fence is the whole of the safety story. The rules
9//! in this module decide *when* a member calls `InitProducerId`. They never
10//! decide who is authoritative, because the coordinator decides that.
11//!
12//! A wrong decision here makes a failover early or late, and it costs epoch
13//! churn. It never makes two members authoritative for one role.
14//!
15//! # Rank comes from the log, not from configuration
16//!
17//! A candidate appends a registration record to the coordination topic. The
18//! offset of that record in the partition is the join sequence of the
19//! candidate. Log compaction keeps the offset of every record it retains, so a
20//! reader that walks the partition in offset order sees the registrations in
21//! registration order. [`RoleStateBuilder`] folds that walk into a
22//! [`RoleState`], and the roster it builds is in offset order.
23//!
24//! A recovered node re-registers. The new record gets a higher offset, so the
25//! node lands at the tail of the roster and takes the last rank. That is the
26//! no-failback rule. A rank from a configuration file would put the recovered
27//! node back at the front, and it would then preempt the member that replaced
28//! it. The cluster would pay for a second failover that it does not need.
29//!
30//! # The stagger is an optimisation, not the safety property
31//!
32//! A challenger of rank `n` waits `n` challenge staggers past the deadline of
33//! the lease. This saves epoch churn, and it does nothing else.
34//! `InitProducerId` is atomic at the coordinator, so a simultaneous challenge
35//! by every standby still mints one epoch per call and still leaves exactly one
36//! member with the newest epoch. The losers keep an older epoch, and each one
37//! learns that it lost on its first guarded write, which the broker fences.
38//! Set the stagger to make that outcome rare. Never set it to make the outcome
39//! safe, because it already is.
40
41use std::collections::HashMap;
42
43use crate::{
44    lease::LeaseConfig,
45    record::{self, CoordinationKey, CoordinationRecord, MemberId, RecordKind, Role},
46};
47
48/// One member of the roster of a role.
49///
50/// `offset` is the offset of the registration record in the coordination
51/// partition, and it is the join sequence of the member. `registered_at` is the
52/// instant the member wrote that record, in milliseconds since the Unix epoch.
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct RosterEntry {
55    /// The member that registered.
56    pub member: MemberId,
57    /// The offset of the registration record of the member.
58    pub offset: i64,
59    /// The time the member registered, in milliseconds since the Unix epoch.
60    pub registered_at: i64,
61}
62
63/// The state that a reader builds from the records of one role.
64///
65/// `roster` is in offset order, which is registration order.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct RoleState {
68    /// The candidates of the role, in registration order.
69    pub roster: Vec<RosterEntry>,
70    /// The lease of the role, and `None` when no member holds it.
71    pub lease: Option<record::Lease>,
72}
73
74impl RoleState {
75    /// Folds the records of one role into a role state.
76    ///
77    /// The caller reads the coordination partition in offset order and passes
78    /// each record with its offset. The fold also gives the right answer for a
79    /// different order, because it keeps the record of the highest offset for
80    /// every key.
81    #[must_use]
82    pub fn from_records<I>(role: Role, records: I) -> Self
83    where
84        I: IntoIterator<Item = (i64, CoordinationKey, CoordinationRecord)>,
85    {
86        let mut builder = RoleStateBuilder::new(role);
87        for (offset, key, record) in records {
88            builder.apply(offset, key, record);
89        }
90        builder.build()
91    }
92
93    /// The member the lease names, and `None` when the role has no lease.
94    ///
95    /// The holder of an expired lease is still the holder. Ask
96    /// [`crate::lease::LeaseTiming::is_live_at`] whether the lease is live.
97    #[must_use]
98    pub fn holder(&self) -> Option<&MemberId> {
99        self.lease.as_ref().map(|lease| &lease.member)
100    }
101
102    /// The roster entry of `member`, and `None` when it did not register.
103    #[must_use]
104    pub fn entry(&self, member: &MemberId) -> Option<&RosterEntry> {
105        self.roster.iter().find(|entry| entry.member == *member)
106    }
107
108    /// The challenge rank of `member`, and `None` when it did not register.
109    ///
110    /// The rank is the index of `member` in the roster after the removal of the
111    /// current holder, so the first standby takes rank 0. The holder itself
112    /// keeps rank 0. A holder only reaches the rank test when its own lease has
113    /// expired, and it still owns the newest epoch at that point, so it
114    /// reclaims the role for less churn than a failover costs.
115    #[must_use]
116    pub fn rank_of(&self, member: &MemberId) -> Option<usize> {
117        self.entry(member)?;
118        let holder = self.holder();
119        if holder == Some(member) {
120            return Some(0);
121        }
122        self.roster
123            .iter()
124            .filter(|entry| Some(&entry.member) != holder)
125            .position(|entry| entry.member == *member)
126    }
127}
128
129/// The slot that one member keeps in a fold.
130///
131/// `registered_at` is `None` after a tombstone. The slot stays in the map, so a
132/// registration of a lower offset that arrives later cannot revive the member.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134struct MemberSlot {
135    offset: i64,
136    registered_at: Option<i64>,
137}
138
139/// The slot that the lease keeps in a fold.
140#[derive(Debug, Clone, PartialEq, Eq)]
141struct LeaseSlot {
142    offset: i64,
143    lease: Option<record::Lease>,
144}
145
146/// Folds the records of one role into a [`RoleState`].
147///
148/// The builder keeps the record of the highest offset for every key, which is
149/// what log compaction keeps. A later registration for a member replaces the
150/// earlier one and moves the member to the tail of the roster. A registration
151/// tombstone removes the member. A lease tombstone clears the lease.
152///
153/// The builder ignores a record of another role, so a caller folds a partition
154/// that holds several roles without a filter of its own.
155#[derive(Debug, Clone)]
156pub struct RoleStateBuilder {
157    role: Role,
158    members: HashMap<MemberId, MemberSlot>,
159    lease: Option<LeaseSlot>,
160}
161
162impl RoleStateBuilder {
163    /// Builds an empty fold for `role`.
164    #[must_use]
165    pub fn new(role: Role) -> Self {
166        Self {
167            role,
168            members: HashMap::new(),
169            lease: None,
170        }
171    }
172
173    /// The role that this fold collects.
174    #[must_use]
175    pub const fn role(&self) -> &Role {
176        &self.role
177    }
178
179    /// Applies one record of the coordination partition.
180    ///
181    /// The key carries the identity, because the key is the compaction key. The
182    /// builder takes the member from `key` and not from the registration value,
183    /// and it takes the record kind from `key` too. It ignores a value whose
184    /// kind does not match the kind of the key. `decode_value` reads the kind
185    /// from the key, so a decoder cannot produce such a pair.
186    pub fn apply(&mut self, offset: i64, key: CoordinationKey, record: CoordinationRecord) {
187        if key.role != self.role {
188            return;
189        }
190        match (key.kind, record) {
191            (RecordKind::Registration, CoordinationRecord::Registration(registration)) => {
192                if let Some(member) = key.member {
193                    self.put_member(offset, member, Some(registration.registered_at));
194                }
195            }
196            (RecordKind::Registration, CoordinationRecord::Tombstone) => {
197                if let Some(member) = key.member {
198                    self.put_member(offset, member, None);
199                }
200            }
201            (RecordKind::Lease, CoordinationRecord::Lease(lease)) => {
202                self.put_lease(offset, Some(lease));
203            }
204            (RecordKind::Lease, CoordinationRecord::Tombstone) => self.put_lease(offset, None),
205            (RecordKind::Registration, CoordinationRecord::Lease(_))
206            | (RecordKind::Lease, CoordinationRecord::Registration(_)) => {}
207        }
208    }
209
210    /// Builds the role state, with the roster in offset order.
211    #[must_use]
212    pub fn build(self) -> RoleState {
213        let mut roster: Vec<RosterEntry> = self
214            .members
215            .into_iter()
216            .filter_map(|(member, slot)| {
217                slot.registered_at.map(|registered_at| RosterEntry {
218                    member,
219                    offset: slot.offset,
220                    registered_at,
221                })
222            })
223            .collect();
224        roster.sort_unstable_by_key(|entry| entry.offset);
225        RoleState {
226            roster,
227            lease: self.lease.and_then(|slot| slot.lease),
228        }
229    }
230
231    fn put_member(&mut self, offset: i64, member: MemberId, registered_at: Option<i64>) {
232        let slot = MemberSlot {
233            offset,
234            registered_at,
235        };
236        match self.members.get_mut(&member) {
237            Some(held) if held.offset >= offset => {}
238            Some(held) => *held = slot,
239            None => {
240                self.members.insert(member, slot);
241            }
242        }
243    }
244
245    fn put_lease(&mut self, offset: i64, lease: Option<record::Lease>) {
246        if self
247            .lease
248            .as_ref()
249            .is_some_and(|held| held.offset >= offset)
250        {
251            return;
252        }
253        self.lease = Some(LeaseSlot { offset, lease });
254    }
255}
256
257/// What one member does about a role right now.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
259pub enum Decision {
260    /// This member holds the role, and its lease is live. It renews the lease
261    /// and it does not call `InitProducerId`.
262    Hold,
263    /// This member may call `InitProducerId` now.
264    Challenge,
265    /// This member waits, and then evaluates the role state again.
266    Wait {
267        /// The instant of the next evaluation, in milliseconds since the Unix
268        /// epoch.
269        until_millis: i64,
270    },
271    /// This member is not in the roster of the role, so it has no rank. It
272    /// registers first, reads the partition again, and evaluates again.
273    NotRegistered,
274}
275
276/// Decides what `me` does about the role of `state` at `now_millis`.
277///
278/// The rules are:
279///
280/// 1. A member that is not in the roster gets [`Decision::NotRegistered`]. It
281///    has no rank, because rank comes from the registration record.
282/// 2. A member that the lease names, while that lease is live, gets
283///    [`Decision::Hold`].
284/// 3. A challenger of rank `n` gets [`Decision::Challenge`] from
285///    `deadline + n * challenge_stagger` on. With no lease, the anchor is the
286///    registration instant of the member instead of a deadline, so rank 0
287///    challenges at once and rank `n` challenges `n` staggers later.
288/// 4. Every other member gets [`Decision::Wait`], and the instant it carries
289///    is the earliest instant at which this answer changes for an unchanged
290///    role state.
291///
292/// The anchor of rule 3 always comes from the role state and never from
293/// `now_millis`. A member that has no lease to wait for anchors on its own
294/// registration record. An anchor of "now plus `n` staggers" would move forward
295/// on every evaluation, and a standby of rank 1 or more would then wait for
296/// ever while rank 0 is dead.
297///
298/// The member of an expired lease keeps rank 0, so it reclaims its own role at
299/// its own deadline. See [`RoleState::rank_of`].
300///
301/// A caller re-evaluates when it reads a new record, and at the latest at the
302/// instant that [`Decision::Wait`] names.
303#[must_use]
304pub fn evaluate(
305    state: &RoleState,
306    me: &MemberId,
307    now_millis: i64,
308    config: &LeaseConfig,
309) -> Decision {
310    let (Some(entry), Some(rank)) = (state.entry(me), state.rank_of(me)) else {
311        return Decision::NotRegistered;
312    };
313    let challenge_at_millis = match state.lease.as_ref() {
314        Some(lease) => {
315            let timing = config.timing(lease);
316            if lease.member == *me && timing.is_live_at(now_millis) {
317                return Decision::Hold;
318            }
319            timing.challenge_at_millis(rank)
320        }
321        None => entry
322            .registered_at
323            .saturating_add(config.challenge_delay_millis(rank)),
324    };
325    if now_millis >= challenge_at_millis {
326        Decision::Challenge
327    } else {
328        Decision::Wait {
329            until_millis: challenge_at_millis,
330        }
331    }
332}
333
334#[cfg(test)]
335mod model;
336
337#[cfg(test)]
338mod tests {
339    use assert2::{assert, check};
340    use krabka_units::secs;
341
342    use super::*;
343    use crate::record::{FencingToken, Registration};
344
345    // One record of the coordination partition, with the offset it sits at.
346    type LogRecord = (i64, CoordinationKey, CoordinationRecord);
347
348    fn role() -> Role {
349        Role::new("controller").expect("the role name is well formed")
350    }
351
352    fn other_role() -> Role {
353        Role::new("compactor").expect("the role name is well formed")
354    }
355
356    fn member(name: &str) -> MemberId {
357        MemberId::new(name).expect("the member id is well formed")
358    }
359
360    fn token(epoch: i16) -> FencingToken {
361        FencingToken::new(11, epoch).expect("the token is not negative")
362    }
363
364    fn config() -> LeaseConfig {
365        LeaseConfig::new(secs(30), secs(10), secs(5)).expect("the extents work together")
366    }
367
368    fn registration(offset: i64, role: &Role, name: &str, registered_at: i64) -> LogRecord {
369        (
370            offset,
371            CoordinationKey::registration(role.clone(), member(name)),
372            CoordinationRecord::Registration(Registration {
373                member: member(name),
374                registered_at,
375            }),
376        )
377    }
378
379    fn deregistration(offset: i64, role: &Role, name: &str) -> LogRecord {
380        (
381            offset,
382            CoordinationKey::registration(role.clone(), member(name)),
383            CoordinationRecord::Tombstone,
384        )
385    }
386
387    fn lease_record(
388        offset: i64,
389        role: &Role,
390        name: &str,
391        granted_at: i64,
392        deadline: i64,
393    ) -> LogRecord {
394        (
395            offset,
396            CoordinationKey::lease(role.clone()),
397            CoordinationRecord::Lease(record::Lease {
398                member: member(name),
399                token: token(1),
400                granted_at,
401                deadline,
402            }),
403        )
404    }
405
406    fn lease_tombstone(offset: i64, role: &Role) -> LogRecord {
407        (
408            offset,
409            CoordinationKey::lease(role.clone()),
410            CoordinationRecord::Tombstone,
411        )
412    }
413
414    fn entry(name: &str, offset: i64, registered_at: i64) -> RosterEntry {
415        RosterEntry {
416            member: member(name),
417            offset,
418            registered_at,
419        }
420    }
421
422    fn state_with(roster: Vec<RosterEntry>, lease: Option<record::Lease>) -> RoleState {
423        RoleState { roster, lease }
424    }
425
426    fn live_lease(name: &str) -> record::Lease {
427        record::Lease {
428            member: member(name),
429            token: token(1),
430            granted_at: 0,
431            deadline: 30_000,
432        }
433    }
434
435    #[test]
436    fn the_roster_follows_offset_order_and_not_arrival_order() {
437        let role = role();
438        let records = vec![
439            registration(30, &role, "node-3", 300),
440            registration(10, &role, "node-1", 100),
441            registration(20, &role, "node-2", 200),
442        ];
443
444        let state = RoleState::from_records(role, records);
445
446        check!(
447            state
448                == state_with(
449                    vec![
450                        entry("node-1", 10, 100),
451                        entry("node-2", 20, 200),
452                        entry("node-3", 30, 300),
453                    ],
454                    None,
455                )
456        );
457    }
458
459    #[test]
460    fn a_later_registration_moves_a_member_to_the_tail() {
461        let role = role();
462        let records = vec![
463            registration(10, &role, "node-1", 100),
464            registration(20, &role, "node-2", 200),
465            registration(30, &role, "node-3", 300),
466            // node-1 recovers and rejoins. It must not preempt its replacement.
467            registration(40, &role, "node-1", 400),
468        ];
469
470        let state = RoleState::from_records(role, records);
471
472        check!(
473            state
474                == state_with(
475                    vec![
476                        entry("node-2", 20, 200),
477                        entry("node-3", 30, 300),
478                        entry("node-1", 40, 400),
479                    ],
480                    None,
481                )
482        );
483        check!(state.rank_of(&member("node-1")) == Some(2));
484    }
485
486    #[test]
487    fn a_tombstone_removes_a_member_and_a_later_record_brings_it_back() {
488        let role = role();
489        let cases: Vec<(&str, Vec<LogRecord>, RoleState)> = vec![
490            (
491                "tombstone removes",
492                vec![
493                    registration(10, &role, "node-1", 100),
494                    registration(20, &role, "node-2", 200),
495                    deregistration(30, &role, "node-1"),
496                ],
497                state_with(vec![entry("node-2", 20, 200)], None),
498            ),
499            (
500                "registration after a tombstone rejoins at the tail",
501                vec![
502                    registration(10, &role, "node-1", 100),
503                    registration(20, &role, "node-2", 200),
504                    deregistration(30, &role, "node-1"),
505                    registration(40, &role, "node-1", 400),
506                ],
507                state_with(
508                    vec![entry("node-2", 20, 200), entry("node-1", 40, 400)],
509                    None,
510                ),
511            ),
512            (
513                "a tombstone of a lower offset does not revive a member",
514                vec![
515                    registration(10, &role, "node-1", 100),
516                    registration(40, &role, "node-1", 400),
517                    deregistration(20, &role, "node-1"),
518                ],
519                state_with(vec![entry("node-1", 40, 400)], None),
520            ),
521        ];
522
523        for (name, records, expected) in cases {
524            let state = RoleState::from_records(role.clone(), records);
525
526            check!(state == expected, "{name}");
527        }
528    }
529
530    #[test]
531    fn the_lease_of_the_highest_offset_wins_and_a_tombstone_clears_it() {
532        let role = role();
533        let cases: Vec<(&str, Vec<LogRecord>, Option<record::Lease>)> = vec![
534            (
535                "the last lease wins",
536                vec![
537                    lease_record(10, &role, "node-1", 0, 30_000),
538                    lease_record(20, &role, "node-2", 10_000, 40_000),
539                ],
540                Some(record::Lease {
541                    member: member("node-2"),
542                    token: token(1),
543                    granted_at: 10_000,
544                    deadline: 40_000,
545                }),
546            ),
547            (
548                "a tombstone clears the lease",
549                vec![
550                    lease_record(10, &role, "node-1", 0, 30_000),
551                    lease_tombstone(20, &role),
552                ],
553                None,
554            ),
555            (
556                "a lease of a lower offset does not overwrite a later one",
557                vec![
558                    lease_record(20, &role, "node-2", 10_000, 40_000),
559                    lease_record(10, &role, "node-1", 0, 30_000),
560                ],
561                Some(record::Lease {
562                    member: member("node-2"),
563                    token: token(1),
564                    granted_at: 10_000,
565                    deadline: 40_000,
566                }),
567            ),
568        ];
569
570        for (name, records, expected) in cases {
571            let state = RoleState::from_records(role.clone(), records);
572
573            check!(state.lease == expected, "{name}");
574        }
575    }
576
577    #[test]
578    fn the_builder_keeps_the_role_it_collects() {
579        let role = role();
580        let mut builder = RoleStateBuilder::new(role.clone());
581        let (offset, key, record) = registration(10, &role, "node-1", 100);
582        builder.apply(offset, key, record);
583
584        check!(builder.role() == &role);
585        check!(builder.build() == state_with(vec![entry("node-1", 10, 100)], None));
586    }
587
588    #[test]
589    fn the_builder_ignores_the_records_of_another_role() {
590        let role = role();
591        let other = other_role();
592        let records = vec![
593            registration(10, &role, "node-1", 100),
594            registration(20, &other, "node-9", 200),
595            lease_record(30, &other, "node-9", 200, 40_000),
596        ];
597
598        let state = RoleState::from_records(role, records);
599
600        check!(state == state_with(vec![entry("node-1", 10, 100)], None));
601    }
602
603    #[test]
604    fn the_holder_is_out_of_the_rank_order_and_keeps_rank_zero() {
605        let state = state_with(
606            vec![
607                entry("node-1", 10, 100),
608                entry("node-2", 20, 200),
609                entry("node-3", 30, 300),
610            ],
611            Some(live_lease("node-2")),
612        );
613
614        check!(state.holder() == Some(&member("node-2")));
615        check!(state.rank_of(&member("node-2")) == Some(0));
616        check!(state.rank_of(&member("node-1")) == Some(0));
617        check!(state.rank_of(&member("node-3")) == Some(1));
618        check!(state.rank_of(&member("node-9")) == None);
619    }
620
621    #[test]
622    fn a_member_outside_the_roster_gets_no_rank_and_no_challenge() {
623        let state = state_with(vec![entry("node-1", 10, 100)], Some(live_lease("node-1")));
624
625        check!(evaluate(&state, &member("node-9"), 0, &config()) == Decision::NotRegistered);
626    }
627
628    #[test]
629    fn the_live_holder_holds_and_every_standby_waits_its_stagger() {
630        let state = state_with(
631            vec![
632                entry("node-1", 10, 100),
633                entry("node-2", 20, 200),
634                entry("node-3", 30, 300),
635            ],
636            Some(live_lease("node-1")),
637        );
638        let config = config();
639
640        let cases = [
641            ("node-1", Decision::Hold),
642            (
643                "node-2",
644                Decision::Wait {
645                    until_millis: 30_000,
646                },
647            ),
648            (
649                "node-3",
650                Decision::Wait {
651                    until_millis: 35_000,
652                },
653            ),
654        ];
655
656        for (name, expected) in cases {
657            check!(
658                evaluate(&state, &member(name), 1_000, &config) == expected,
659                "{name}"
660            );
661        }
662    }
663
664    #[test]
665    fn the_ranks_challenge_one_stagger_apart_after_the_deadline() {
666        let state = state_with(
667            vec![
668                entry("node-1", 10, 100),
669                entry("node-2", 20, 200),
670                entry("node-3", 30, 300),
671            ],
672            Some(live_lease("node-1")),
673        );
674        let config = config();
675
676        let cases = [
677            // The deadline is 30 000. Rank 0 is node-2 and rank 1 is node-3.
678            (
679                29_999,
680                "node-2",
681                Decision::Wait {
682                    until_millis: 30_000,
683                },
684            ),
685            (30_000, "node-2", Decision::Challenge),
686            (
687                30_000,
688                "node-3",
689                Decision::Wait {
690                    until_millis: 35_000,
691                },
692            ),
693            (
694                34_999,
695                "node-3",
696                Decision::Wait {
697                    until_millis: 35_000,
698                },
699            ),
700            (35_000, "node-3", Decision::Challenge),
701            // The deposed holder keeps rank 0 and reclaims at its own deadline.
702            (29_999, "node-1", Decision::Hold),
703            (30_000, "node-1", Decision::Challenge),
704        ];
705
706        for (now_millis, name, expected) in cases {
707            check!(
708                evaluate(&state, &member(name), now_millis, &config) == expected,
709                "{name} at {now_millis}"
710            );
711        }
712    }
713
714    #[test]
715    fn with_no_lease_the_registration_instant_anchors_the_stagger() {
716        let state = state_with(
717            vec![
718                entry("node-1", 10, 1_000),
719                entry("node-2", 20, 2_000),
720                entry("node-3", 30, 3_000),
721            ],
722            None,
723        );
724        let config = config();
725
726        let cases = [
727            (1_000, "node-1", Decision::Challenge),
728            (
729                1_000,
730                "node-2",
731                Decision::Wait {
732                    until_millis: 7_000,
733                },
734            ),
735            (7_000, "node-2", Decision::Challenge),
736            (
737                1_000,
738                "node-3",
739                Decision::Wait {
740                    until_millis: 13_000,
741                },
742            ),
743            (13_000, "node-3", Decision::Challenge),
744        ];
745
746        for (now_millis, name, expected) in cases {
747            check!(
748                evaluate(&state, &member(name), now_millis, &config) == expected,
749                "{name} at {now_millis}"
750            );
751        }
752    }
753
754    #[test]
755    fn a_recovered_member_defers_to_the_member_that_replaced_it() {
756        let role = role();
757        let config = config();
758        let records = vec![
759            registration(10, &role, "node-1", 0),
760            registration(20, &role, "node-2", 0),
761            lease_record(30, &role, "node-1", 0, 30_000),
762            // node-1 dies, node-2 takes the role, and then node-1 comes back.
763            deregistration(40, &role, "node-1"),
764            lease_record(50, &role, "node-2", 30_000, 60_000),
765            registration(60, &role, "node-1", 31_000),
766        ];
767
768        let state = RoleState::from_records(role, records);
769
770        check!(state.holder() == Some(&member("node-2")));
771        check!(state.rank_of(&member("node-1")) == Some(0));
772        // The recovered member is behind the holder in the roster, and the live
773        // lease keeps it waiting for the whole deadline.
774        assert!(let Some(recovered) = state.entry(&member("node-1")));
775        check!(recovered.offset == 60);
776        check!(
777            evaluate(&state, &member("node-1"), 31_000, &config)
778                == Decision::Wait {
779                    until_millis: 60_000
780                }
781        );
782        check!(evaluate(&state, &member("node-2"), 31_000, &config) == Decision::Hold);
783    }
784
785    #[test]
786    fn a_third_member_that_rejoins_ranks_behind_the_other_standby() {
787        let role = role();
788        let config = config();
789        let records = vec![
790            registration(10, &role, "node-1", 0),
791            registration(20, &role, "node-2", 0),
792            registration(30, &role, "node-3", 0),
793            lease_record(40, &role, "node-1", 0, 30_000),
794            deregistration(50, &role, "node-2"),
795            registration(60, &role, "node-2", 5_000),
796        ];
797
798        let state = RoleState::from_records(role, records);
799
800        // node-3 registered before the rejoin of node-2, so node-3 leads.
801        check!(state.rank_of(&member("node-3")) == Some(0));
802        check!(state.rank_of(&member("node-2")) == Some(1));
803        check!(evaluate(&state, &member("node-3"), 30_000, &config) == Decision::Challenge);
804        check!(
805            evaluate(&state, &member("node-2"), 30_000, &config)
806                == Decision::Wait {
807                    until_millis: 35_000
808                }
809        );
810    }
811
812    #[test]
813    fn a_lone_member_with_no_lease_challenges_at_once() {
814        let state = state_with(vec![entry("node-1", 10, 1_000)], None);
815
816        check!(evaluate(&state, &member("node-1"), 1_000, &config()) == Decision::Challenge);
817        check!(evaluate(&state, &member("node-1"), 9_000, &config()) == Decision::Challenge);
818    }
819
820    #[test]
821    fn an_empty_role_state_has_no_holder_and_no_ranks() {
822        let state = RoleState::default();
823
824        check!(state.holder() == None);
825        check!(state.rank_of(&member("node-1")) == None);
826        check!(evaluate(&state, &member("node-1"), 0, &config()) == Decision::NotRegistered);
827    }
828}