use std::collections::HashSet;
use std::time::Duration;
use crate::identity::{ParticipantId, ProducerId};
use crate::bus::liveliness::{ParticipantReadyEvent, ParticipantReadyStatus};
use crate::bus::metadata::ParticipantSourceIdentity;
use crate::bus::time::{LocalInstant, RobotInstant, RobotTimeError};
pub const MAX_READY_PRODUCERS: usize = 16;
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum LeaseRejection {
#[error("participant attribution does not match the fixed source")]
WrongParticipant,
#[error("participant-attributed source cannot acquire an external lease")]
ParticipantSource,
#[error("fixed source has no Ready producer")]
SourceAbsent,
#[error("fixed source has a Ready producer conflict")]
SourceConflict,
#[error("sequence {observed} does not follow {accepted} for the active producer")]
StaleSequence { accepted: u64, observed: u64 },
#[error("external producer {owner} already owns the lease")]
AuthorityHeld { owner: ProducerId },
#[error("producer {requested} does not own the external lease held by {owner}")]
NotOwner {
owner: ProducerId,
requested: ProducerId,
},
#[error("Ready observation state overflowed")]
ReadyStateOverflow,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LeaseDecision {
Renewed,
Acquired,
Rejected(LeaseRejection),
}
pub const LEASE_TRACE_TARGET: &str = "phoxal.lease";
#[derive(Clone, Debug, PartialEq, Eq)]
struct Held<B> {
body: B,
observed_at: LocalInstant,
producer: ProducerId,
sequence: u64,
observation: u64,
accepted_at: Option<RobotInstant>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FixedSourceLease<B> {
input: &'static str,
expected_participant: ParticipantId,
silence: Duration,
hold: Duration,
ready: HashSet<ProducerId>,
ready_overflow: bool,
active: Option<(ProducerId, u64)>,
held: Option<Held<B>>,
observations: u64,
}
impl<B> FixedSourceLease<B> {
pub fn new(
input: &'static str,
expected_participant: ParticipantId,
silence: Duration,
hold: Duration,
) -> Self {
Self {
input,
expected_participant,
silence,
hold,
ready: HashSet::new(),
ready_overflow: false,
active: None,
held: None,
observations: 0,
}
}
pub fn expected_participant(&self) -> &ParticipantId {
&self.expected_participant
}
pub fn update_ready(
&mut self,
source: &ParticipantSourceIdentity,
status: ParticipantReadyStatus,
) {
if source.participant != self.expected_participant {
return;
}
let producer = source.producer;
match status {
ParticipantReadyStatus::Ready => {
if !self.ready.contains(&producer) && self.ready.len() >= MAX_READY_PRODUCERS {
self.ready_overflow = true;
} else {
self.ready.insert(producer);
}
}
ParticipantReadyStatus::Lost => {
self.ready.remove(&producer);
if self.active.is_some_and(|(active, _)| active == producer) {
self.active = None;
self.held = None;
}
}
}
self.reconcile_ready();
}
pub fn update_ready_event(&mut self, event: &ParticipantReadyEvent) {
if event.source.participant == self.expected_participant {
self.update_ready(&event.source, event.status);
}
}
pub fn mark_ready_overflow(&mut self) {
self.ready_overflow = true;
self.reconcile_ready();
}
pub fn ready_count(&self) -> usize {
self.ready.len()
}
pub fn producer(&self) -> Option<ProducerId> {
self.held.as_ref().map(|held| held.producer)
}
pub const fn observations(&self) -> u64 {
self.observations
}
pub fn offer(
&mut self,
source: Option<&ParticipantSourceIdentity>,
sequence: u64,
observed_at: LocalInstant,
body: B,
) -> LeaseDecision {
let producer = source.map(|source| source.producer);
let decision =
if source.map(|source| &source.participant) != Some(&self.expected_participant) {
LeaseDecision::Rejected(LeaseRejection::WrongParticipant)
} else if self.ready_overflow {
LeaseDecision::Rejected(LeaseRejection::ReadyStateOverflow)
} else if self.ready.is_empty() {
LeaseDecision::Rejected(LeaseRejection::SourceAbsent)
} else if self.ready.len() != 1
|| !producer.is_some_and(|producer| self.ready.contains(&producer))
{
LeaseDecision::Rejected(LeaseRejection::SourceConflict)
} else if let Some((active, accepted)) = self.active {
if Some(active) != producer {
LeaseDecision::Rejected(LeaseRejection::SourceConflict)
} else if sequence <= accepted {
LeaseDecision::Rejected(LeaseRejection::StaleSequence {
accepted,
observed: sequence,
})
} else {
LeaseDecision::Renewed
}
} else {
LeaseDecision::Acquired
};
self.observations = self.observations.saturating_add(1);
let Some(producer) = producer else {
return decision;
};
self.trace(producer, sequence, decision);
if matches!(decision, LeaseDecision::Renewed | LeaseDecision::Acquired) {
self.active = Some((producer, sequence));
self.held = Some(Held {
body,
observed_at,
producer,
sequence,
observation: self.observations,
accepted_at: None,
});
}
decision
}
pub fn live(&mut self, now: LocalInstant, step: RobotInstant) -> Option<&B> {
self.live_host(now)?;
let held = self.held.as_mut()?;
let (producer, sequence, observation) = (held.producer, held.sequence, held.observation);
let anchor = match held.accepted_at {
Some(anchor) => anchor,
None => *held.accepted_at.insert(step),
};
match step.duration_since(anchor) {
Ok(elapsed) if elapsed < self.hold => Some(&self.held.as_ref()?.body),
Ok(_) => {
self.trace_expiry(producer, sequence, observation, "expired_hold");
self.held = None;
None
}
Err(RobotTimeError::TimelineMismatch(_)) => {
self.trace_expiry(producer, sequence, observation, "timeline_replaced");
self.held = None;
None
}
Err(RobotTimeError::Reversed { .. }) => {
self.trace_expiry(producer, sequence, observation, "time_reversed");
self.held = None;
None
}
}
}
pub fn live_host(&mut self, now: LocalInstant) -> Option<&B> {
let held = self.held.as_ref()?;
let expired = now.saturating_duration_since(held.observed_at) >= self.silence;
if !expired {
return self.held.as_ref().map(|held| &held.body);
}
let held = self.held.take()?;
let (producer, sequence, observation) = (held.producer, held.sequence, held.observation);
self.trace_expiry(producer, sequence, observation, "expired_silence");
None
}
pub fn clear(&mut self) {
self.held = None;
}
fn reconcile_ready(&mut self) {
let sole = (!self.ready_overflow && self.ready.len() == 1)
.then(|| self.ready.iter().next().copied())
.flatten();
if let Some((active, _)) = self.active
&& !self.ready.contains(&active)
&& sole != Some(active)
{
self.active = None;
}
let still_owned = self
.held
.as_ref()
.is_some_and(|held| sole == Some(held.producer));
if !still_owned {
self.held = None;
}
}
fn trace(&self, producer: ProducerId, sequence: u64, decision: LeaseDecision) {
match decision {
LeaseDecision::Rejected(_) => tracing::info!(
target: LEASE_TRACE_TARGET,
input = self.input,
expected_participant = %self.expected_participant,
producer = %producer,
sequence,
observation = self.observations,
decision = ?decision,
ready_count = self.ready.len(),
"fixed-source authority decision"
),
LeaseDecision::Acquired | LeaseDecision::Renewed => tracing::debug!(
target: LEASE_TRACE_TARGET,
input = self.input,
expected_participant = %self.expected_participant,
producer = %producer,
sequence,
observation = self.observations,
decision = ?decision,
ready_count = self.ready.len(),
"fixed-source authority decision"
),
}
}
fn trace_expiry(&self, producer: ProducerId, sequence: u64, observation: u64, decision: &str) {
tracing::debug!(
target: LEASE_TRACE_TARGET,
input = self.input,
expected_participant = %self.expected_participant,
producer = %producer,
sequence,
observation,
decision,
"fixed-source authority expired"
);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FixedSourceAdmission {
expected_participant: ParticipantId,
ready: HashSet<ProducerId>,
ready_overflow: bool,
active: Option<(ProducerId, u64)>,
ready_generation: u64,
}
impl FixedSourceAdmission {
pub fn new(expected_participant: ParticipantId) -> Self {
Self {
expected_participant,
ready: HashSet::new(),
ready_overflow: false,
active: None,
ready_generation: 0,
}
}
pub fn expected_participant(&self) -> &ParticipantId {
&self.expected_participant
}
pub fn update_ready(
&mut self,
source: &ParticipantSourceIdentity,
status: ParticipantReadyStatus,
) {
if source.participant != self.expected_participant {
return;
}
match status {
ParticipantReadyStatus::Ready => {
if !self.ready.contains(&source.producer) && self.ready.len() >= MAX_READY_PRODUCERS
{
self.ready_overflow = true;
} else {
self.ready.insert(source.producer);
}
}
ParticipantReadyStatus::Lost => {
self.ready.remove(&source.producer);
let Some(next_generation) = self.ready_generation.checked_add(1) else {
self.ready_overflow = true;
self.active = None;
return;
};
self.ready_generation = next_generation;
if self
.active
.is_some_and(|(producer, _)| producer == source.producer)
{
self.active = None;
}
}
}
}
pub fn update_ready_event(&mut self, event: &ParticipantReadyEvent) {
self.update_ready(&event.source, event.status);
}
pub fn mark_ready_overflow(&mut self) {
self.ready_overflow = true;
}
pub fn ready_count(&self) -> usize {
self.ready.len()
}
pub fn ready_generation(&self) -> u64 {
self.ready_generation
}
pub fn is_current(&self, source: Option<&ParticipantSourceIdentity>, sequence: u64) -> bool {
let Some(source) = source else {
return false;
};
!self.ready_overflow
&& self.ready.len() == 1
&& self.ready.contains(&source.producer)
&& self.active == Some((source.producer, sequence))
}
pub fn offer(
&mut self,
source: Option<&ParticipantSourceIdentity>,
sequence: u64,
) -> LeaseDecision {
let Some(source) = source else {
return LeaseDecision::Rejected(LeaseRejection::WrongParticipant);
};
if source.participant != self.expected_participant {
return LeaseDecision::Rejected(LeaseRejection::WrongParticipant);
}
if self.ready_overflow {
return LeaseDecision::Rejected(LeaseRejection::ReadyStateOverflow);
}
if self.ready.is_empty() {
return LeaseDecision::Rejected(LeaseRejection::SourceAbsent);
}
if self.ready.len() != 1 || !self.ready.contains(&source.producer) {
return LeaseDecision::Rejected(LeaseRejection::SourceConflict);
}
let decision = match self.active {
Some((producer, _accepted)) if producer != source.producer => {
LeaseDecision::Rejected(LeaseRejection::SourceConflict)
}
Some((_producer, accepted)) if sequence <= accepted => {
LeaseDecision::Rejected(LeaseRejection::StaleSequence {
accepted,
observed: sequence,
})
}
Some(_) => LeaseDecision::Renewed,
None => LeaseDecision::Acquired,
};
if matches!(decision, LeaseDecision::Acquired | LeaseDecision::Renewed) {
self.active = Some((source.producer, sequence));
}
decision
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExclusiveProducerLease<B> {
input: &'static str,
silence: Duration,
hold: Duration,
owner: Option<(ProducerId, u64)>,
held: Option<Held<B>>,
observations: u64,
}
impl<B> ExclusiveProducerLease<B> {
pub fn new(input: &'static str, silence: Duration, hold: Duration) -> Self {
Self {
input,
silence,
hold,
owner: None,
held: None,
observations: 0,
}
}
pub fn producer(&self) -> Option<ProducerId> {
self.owner.map(|(producer, _)| producer)
}
pub fn offer(
&mut self,
source: &crate::bus::metadata::SourceAttribution,
sequence: u64,
observed_at: LocalInstant,
body: B,
) -> LeaseDecision {
let producer = source.producer();
let decision = if source.participant_source().is_some() {
LeaseDecision::Rejected(LeaseRejection::ParticipantSource)
} else {
match self.owner {
None => LeaseDecision::Acquired,
Some((owner, _accepted)) if owner != producer => {
LeaseDecision::Rejected(LeaseRejection::AuthorityHeld { owner })
}
Some((_owner, accepted)) if sequence <= accepted => {
LeaseDecision::Rejected(LeaseRejection::StaleSequence {
accepted,
observed: sequence,
})
}
Some(_) => LeaseDecision::Renewed,
}
};
self.observations = self.observations.saturating_add(1);
match decision {
LeaseDecision::Rejected(_) => tracing::info!(
target: LEASE_TRACE_TARGET,
input = self.input,
producer = %producer,
sequence,
observation = self.observations,
decision = ?decision,
"external authority decision"
),
LeaseDecision::Acquired | LeaseDecision::Renewed => tracing::debug!(
target: LEASE_TRACE_TARGET,
input = self.input,
producer = %producer,
sequence,
observation = self.observations,
decision = ?decision,
"external authority decision"
),
}
if matches!(decision, LeaseDecision::Acquired | LeaseDecision::Renewed) {
self.owner = Some((producer, sequence));
self.held = Some(Held {
body,
observed_at,
producer,
sequence,
observation: self.observations,
accepted_at: None,
});
}
decision
}
pub fn release(&mut self, producer: ProducerId) -> Result<(), LeaseRejection> {
let Some((owner, _)) = self.owner else {
return Ok(());
};
if owner != producer {
return Err(LeaseRejection::NotOwner {
owner,
requested: producer,
});
}
self.owner = None;
self.held = None;
Ok(())
}
pub fn live(&mut self, now: LocalInstant, step: RobotInstant) -> Option<&B> {
self.expire_before_offer(now, step);
let held = self.held.as_mut()?;
let (producer, sequence, observation) = (held.producer, held.sequence, held.observation);
let anchor = match held.accepted_at {
Some(anchor) => anchor,
None => *held.accepted_at.insert(step),
};
match step.duration_since(anchor) {
Ok(elapsed) if elapsed < self.hold => Some(&self.held.as_ref()?.body),
Ok(_) => {
self.expire(producer, sequence, observation, "expired_hold");
None
}
Err(RobotTimeError::TimelineMismatch(_)) => {
self.expire(producer, sequence, observation, "timeline_replaced");
None
}
Err(RobotTimeError::Reversed { .. }) => {
self.expire(producer, sequence, observation, "time_reversed");
None
}
}
}
pub fn clear(&mut self) {
self.owner = None;
self.held = None;
}
pub fn expire_before_offer(&mut self, now: LocalInstant, step: RobotInstant) {
self.expire_host(now);
let Some(held) = self.held.as_ref() else {
return;
};
let Some(anchor) = held.accepted_at else {
return;
};
let reason = match step.duration_since(anchor) {
Ok(elapsed) if elapsed >= self.hold => Some("expired_hold"),
Err(RobotTimeError::TimelineMismatch(_)) => Some("timeline_replaced"),
Err(RobotTimeError::Reversed { .. }) => Some("time_reversed"),
_ => None,
};
if let Some(reason) = reason {
let (producer, sequence, observation) =
(held.producer, held.sequence, held.observation);
self.expire(producer, sequence, observation, reason);
}
}
pub fn expire_host(&mut self, now: LocalInstant) {
let Some(held) = self.held.as_ref() else {
return;
};
if now.saturating_duration_since(held.observed_at) >= self.silence {
let (producer, sequence, observation) =
(held.producer, held.sequence, held.observation);
self.expire(producer, sequence, observation, "expired_silence");
}
}
fn expire(&mut self, producer: ProducerId, sequence: u64, observation: u64, decision: &str) {
tracing::debug!(
target: LEASE_TRACE_TARGET,
input = self.input,
producer = %producer,
sequence,
observation,
decision,
"external authority expired"
);
self.owner = None;
self.held = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::TimelineId;
fn producer(value: u128) -> ProducerId {
ProducerId::try_from((1_u128 << 124) | value).expect("canonical test producer")
}
fn participant(name: &str) -> ParticipantId {
ParticipantId::new(name).expect("valid test participant")
}
fn source_identity(
participant: &ParticipantId,
producer: ProducerId,
) -> ParticipantSourceIdentity {
ParticipantSourceIdentity::new(participant.clone(), producer)
}
fn external(producer: ProducerId) -> crate::bus::metadata::SourceAttribution {
crate::bus::metadata::SourceAttribution::External {
producer,
label: None,
}
}
fn step(line: TimelineId, ticks: u64) -> RobotInstant {
RobotInstant::new(line, ticks)
}
#[test]
fn fixed_source_requires_exact_ready_source_and_participant() {
let expected = participant("motion");
let wrong = participant("drive");
let source = producer(1);
let mut lease = FixedSourceLease::new(
"drive/target",
expected.clone(),
Duration::from_secs(1),
Duration::from_secs(1),
);
let at = LocalInstant::from_boot_ns(0);
assert_eq!(
lease.offer(Some(&source_identity(&expected, source)), 1, at, "blocked"),
LeaseDecision::Rejected(LeaseRejection::SourceAbsent)
);
lease.update_ready(
&source_identity(&wrong, source),
ParticipantReadyStatus::Ready,
);
assert_eq!(lease.ready_count(), 0);
assert_eq!(
lease.offer(Some(&source_identity(&wrong, source)), 2, at, "wrong"),
LeaseDecision::Rejected(LeaseRejection::WrongParticipant)
);
lease.update_ready(
&source_identity(&expected, source),
ParticipantReadyStatus::Ready,
);
assert_eq!(
lease.offer(Some(&source_identity(&expected, source)), 1, at, "ok"),
LeaseDecision::Acquired
);
}
#[test]
fn fixed_source_conflict_never_allows_arrival_order_takeover() {
let expected = participant("motion");
let first = producer(2);
let second = producer(3);
let at = LocalInstant::from_boot_ns(0);
let mut lease = FixedSourceLease::new(
"drive/target",
expected.clone(),
Duration::from_secs(1),
Duration::from_secs(1),
);
lease.update_ready(
&source_identity(&expected, first),
ParticipantReadyStatus::Ready,
);
assert_eq!(
lease.offer(Some(&source_identity(&expected, first)), 7, at, "old"),
LeaseDecision::Acquired
);
lease.update_ready(
&source_identity(&expected, second),
ParticipantReadyStatus::Ready,
);
assert_eq!(lease.producer(), None);
assert_eq!(
lease.offer(Some(&source_identity(&expected, second)), 0, at, "new"),
LeaseDecision::Rejected(LeaseRejection::SourceConflict)
);
lease.update_ready(
&source_identity(&expected, first),
ParticipantReadyStatus::Lost,
);
assert_eq!(
lease.offer(Some(&source_identity(&expected, second)), 0, at, "new"),
LeaseDecision::Acquired
);
assert_eq!(lease.producer(), Some(second));
}
#[test]
fn fixed_source_only_tracks_current_ready_state_and_rejects_replays() {
let expected = participant("motion");
let source = producer(4);
let at = LocalInstant::from_boot_ns(0);
let mut lease = FixedSourceLease::new(
"drive/target",
expected.clone(),
Duration::from_secs(1),
Duration::from_secs(1),
);
lease.update_ready(
&source_identity(&expected, source),
ParticipantReadyStatus::Ready,
);
assert_eq!(
lease.offer(Some(&source_identity(&expected, source)), 3, at, "first"),
LeaseDecision::Acquired
);
assert_eq!(
lease.offer(Some(&source_identity(&expected, source)), 3, at, "replay"),
LeaseDecision::Rejected(LeaseRejection::StaleSequence {
accepted: 3,
observed: 3,
})
);
assert!(
lease
.live(
at.saturating_add(Duration::from_secs(1)),
step(TimelineId::mint(), 0),
)
.is_none()
);
assert_eq!(
lease.offer(
Some(&source_identity(&expected, source)),
1,
at,
"expired replay"
),
LeaseDecision::Rejected(LeaseRejection::StaleSequence {
accepted: 3,
observed: 1,
})
);
lease.update_ready(
&source_identity(&expected, source),
ParticipantReadyStatus::Lost,
);
lease.update_ready(
&source_identity(&expected, source),
ParticipantReadyStatus::Ready,
);
assert_eq!(
lease.offer(
Some(&source_identity(&expected, source)),
0,
at,
"new incarnation"
),
LeaseDecision::Acquired
);
}
#[test]
fn fixed_source_admission_rejects_a_mismatched_clear_before_keep_last() {
let expected = participant("safety");
let mismatched = participant("operator");
let authoritative = source_identity(&expected, producer(14));
let mismatched = source_identity(&mismatched, producer(15));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&authoritative, ParticipantReadyStatus::Ready);
let mut keep_last = None;
if matches!(
admission.offer(Some(&authoritative), 1),
LeaseDecision::Acquired | LeaseDecision::Renewed
) {
keep_last = Some("authoritative stop");
}
assert_eq!(
admission.offer(Some(&mismatched), 99),
LeaseDecision::Rejected(LeaseRejection::WrongParticipant)
);
assert_eq!(keep_last, Some("authoritative stop"));
}
#[test]
fn fixed_source_admission_rejects_after_ready_loss_before_step_and_keeps_a() {
let expected = participant("safety");
let identity = source_identity(&expected, producer(16));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
assert_eq!(admission.offer(Some(&identity), 1), LeaseDecision::Acquired);
let keep_last = Some("product A");
admission.update_ready(&identity, ParticipantReadyStatus::Lost);
assert_eq!(admission.ready_count(), 0);
assert_eq!(
admission.offer(Some(&identity), 2),
LeaseDecision::Rejected(LeaseRejection::SourceAbsent)
);
assert_eq!(keep_last, Some("product A"));
}
#[test]
fn fixed_source_admission_rejects_sustained_mismatched_offers_before_keep_last() {
let expected = participant("navigation");
let source = source_identity(&expected, producer(18));
let mismatched = source_identity(&participant("operator"), producer(19));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&source, ParticipantReadyStatus::Ready);
let keep_last = Some("candidate A");
assert_eq!(admission.offer(Some(&source), 1), LeaseDecision::Acquired);
for sequence in 2..=100 {
assert_eq!(
admission.offer(Some(&mismatched), sequence),
LeaseDecision::Rejected(LeaseRejection::WrongParticipant)
);
}
assert_eq!(keep_last, Some("candidate A"));
}
#[test]
fn fixed_source_admission_requires_fresh_publication_after_ready_reappears() {
let expected = participant("navigation");
let identity = source_identity(&expected, producer(20));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
assert_eq!(admission.offer(Some(&identity), 1), LeaseDecision::Acquired);
assert!(admission.is_current(Some(&identity), 1));
admission.update_ready(&identity, ParticipantReadyStatus::Lost);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
assert!(!admission.is_current(Some(&identity), 1));
assert_eq!(
admission.offer(Some(&identity), 1),
LeaseDecision::Acquired,
"only a fresh ingress observation may establish the new incarnation"
);
}
#[test]
fn fixed_source_admission_generation_exhaustion_fails_closed() {
let expected = participant("navigation");
let identity = source_identity(&expected, producer(23));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
assert_eq!(admission.offer(Some(&identity), 1), LeaseDecision::Acquired);
admission.ready_generation = u64::MAX;
admission.update_ready(&identity, ParticipantReadyStatus::Lost);
assert!(!admission.is_current(Some(&identity), 1));
assert_eq!(
admission.offer(Some(&identity), 2),
LeaseDecision::Rejected(LeaseRejection::ReadyStateOverflow)
);
}
#[test]
fn fixed_source_admission_and_liveness_lease_promote_t2_after_reset() {
let expected = participant("navigation");
let identity = source_identity(&expected, producer(21));
let at = LocalInstant::from_boot_ns(0);
let line = TimelineId::mint();
let mut admission = FixedSourceAdmission::new(expected.clone());
let mut lease = FixedSourceLease::new(
"navigation/candidate",
expected,
Duration::from_secs(1),
Duration::from_secs(1),
);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
lease.update_ready(&identity, ParticipantReadyStatus::Ready);
assert_eq!(admission.offer(Some(&identity), 1), LeaseDecision::Acquired);
assert_eq!(
lease.offer(Some(&identity), 1, at, "T1 candidate"),
LeaseDecision::Acquired
);
assert_eq!(admission.offer(Some(&identity), 2), LeaseDecision::Renewed);
lease.clear();
assert_eq!(
lease.offer(Some(&identity), 2, at, "T2 candidate"),
LeaseDecision::Renewed
);
assert_eq!(
lease.live(at, RobotInstant::new(line, 0)),
Some(&"T2 candidate")
);
}
#[test]
fn fixed_source_admission_overflow_blocks_new_values_without_body_state() {
let expected = participant("safety");
let identity = source_identity(&expected, producer(17));
let mut admission = FixedSourceAdmission::new(expected);
admission.update_ready(&identity, ParticipantReadyStatus::Ready);
admission.mark_ready_overflow();
assert_eq!(
admission.offer(Some(&identity), 1),
LeaseDecision::Rejected(LeaseRejection::ReadyStateOverflow)
);
}
#[test]
fn external_control_has_no_participant_or_ready_prerequisite_and_no_steal() {
let first = producer(5);
let second = producer(6);
let at = LocalInstant::from_boot_ns(0);
let mut lease = ExclusiveProducerLease::new(
"motion/manual",
Duration::from_secs(1),
Duration::from_secs(1),
);
assert_eq!(
lease.offer(&external(first), 0, at, "first"),
LeaseDecision::Acquired
);
assert_eq!(
lease.offer(&external(second), 99, at, "steal"),
LeaseDecision::Rejected(LeaseRejection::AuthorityHeld { owner: first })
);
assert_eq!(lease.producer(), Some(first));
assert_eq!(
lease.release(second),
Err(LeaseRejection::NotOwner {
owner: first,
requested: second,
})
);
assert!(lease.release(first).is_ok());
assert_eq!(
lease.offer(&external(second), 0, at, "second"),
LeaseDecision::Acquired
);
}
#[test]
fn external_lease_rejects_participant_attribution() {
let source = ParticipantSourceIdentity::new(participant("motion"), producer(13));
let at = LocalInstant::from_boot_ns(0);
let mut lease = ExclusiveProducerLease::new(
"motion/manual",
Duration::from_secs(1),
Duration::from_secs(1),
);
assert_eq!(
lease.offer(
&crate::bus::metadata::SourceAttribution::Participant(source),
0,
at,
"participant body",
),
LeaseDecision::Rejected(LeaseRejection::ParticipantSource)
);
assert_eq!(lease.producer(), None);
}
#[test]
fn external_expiry_releases_authority() {
let first = producer(7);
let second = producer(8);
let at = LocalInstant::from_boot_ns(0);
let line = TimelineId::mint();
let mut lease = ExclusiveProducerLease::new(
"motion/manual",
Duration::from_millis(10),
Duration::from_secs(1),
);
lease.offer(&external(first), 0, at, "first");
assert!(
lease
.live(at.saturating_add(Duration::from_millis(10)), step(line, 0))
.is_none()
);
assert_eq!(
lease.offer(&external(second), 0, at, "second"),
LeaseDecision::Acquired
);
}
#[test]
fn a_silent_owner_is_replaced_by_the_first_next_step_offer() {
let first = producer(9);
let second = producer(10);
let start = LocalInstant::from_boot_ns(0);
let after_silence = start.saturating_add(Duration::from_millis(10));
let mut lease = ExclusiveProducerLease::new(
"motion/manual",
Duration::from_millis(10),
Duration::from_secs(1),
);
assert_eq!(
lease.offer(&external(first), 0, start, "first"),
LeaseDecision::Acquired
);
lease.expire_host(after_silence);
assert_eq!(
lease.offer(&external(second), 0, after_silence, "second"),
LeaseDecision::Acquired
);
assert_eq!(lease.producer(), Some(second));
let line = TimelineId::mint();
assert_eq!(lease.live(after_silence, step(line, 0)), Some(&"second"));
}
#[test]
fn a_logically_expired_owner_is_replaced_before_the_next_offer() {
let first = producer(11);
let second = producer(12);
let host_start = LocalInstant::from_boot_ns(0);
let line = TimelineId::mint();
let hold = Duration::from_millis(10);
let mut lease = ExclusiveProducerLease::new("motion/manual", Duration::from_secs(1), hold);
assert_eq!(
lease.offer(&external(first), 0, host_start, "first"),
LeaseDecision::Acquired
);
assert_eq!(lease.live(host_start, step(line, 0)), Some(&"first"));
let after_hold = step(line, u64::try_from(hold.as_nanos()).unwrap() + 1);
lease.expire_before_offer(
host_start.saturating_add(Duration::from_millis(1)),
after_hold,
);
assert_eq!(
lease.offer(
&external(second),
0,
host_start.saturating_add(Duration::from_millis(1)),
"second"
),
LeaseDecision::Acquired
);
assert_eq!(lease.producer(), Some(second));
}
}