use meerkat_core::PeerInputClass;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct RawItemId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct PeerId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RawPeerKind {
Request,
ResponseTerminal,
ResponseProgress,
PlainEvent,
SilentRequest,
Message,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ContentShape {
Text,
Blocks,
Mixed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RequestId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReservationKey(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PeerIngressState {
Absent,
Received,
Dropped,
Delivered,
}
impl PeerIngressState {
pub(crate) fn is_terminal(self) -> bool {
matches!(self, Self::Dropped | Self::Delivered)
}
}
#[derive(Debug, Clone)]
pub(crate) enum PeerCommsInput {
TrustPeer {
peer_id: PeerId,
},
ReceivePeerEnvelope {
raw_item_id: RawItemId,
peer_id: PeerId,
raw_kind: RawPeerKind,
text_projection: String,
content_shape: ContentShape,
request_id: Option<RequestId>,
reservation_key: Option<ReservationKey>,
},
SubmitTypedPeerInput {
raw_item_id: RawItemId,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubmitPeerInputCandidate {
pub raw_item_id: RawItemId,
pub peer_input_class: PeerInputClass,
pub text_projection: String,
pub content_shape: ContentShape,
pub request_id: Option<RequestId>,
pub reservation_key: Option<ReservationKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PeerCommsEffect {
SubmitPeerInputCandidate(SubmitPeerInputCandidate),
}
#[derive(Debug)]
pub(crate) struct PeerCommsTransition {
pub next_phase: PeerIngressState,
pub effects: Vec<PeerCommsEffect>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum PeerCommsError {
#[error("illegal transition from {from:?}: {reason}")]
IllegalTransition {
from: PeerIngressState,
reason: String,
},
#[error("guard failed: {guard} (from {from:?})")]
GuardFailed {
from: PeerIngressState,
guard: String,
},
#[error("missing state for raw item {0:?}")]
MissingItemState(RawItemId),
}
fn class_for(raw_kind: &RawPeerKind) -> PeerInputClass {
match raw_kind {
RawPeerKind::Request => PeerInputClass::ActionableRequest,
RawPeerKind::ResponseTerminal => PeerInputClass::Response,
RawPeerKind::ResponseProgress => PeerInputClass::Response,
RawPeerKind::PlainEvent => PeerInputClass::PlainEvent,
RawPeerKind::SilentRequest => PeerInputClass::SilentRequest,
RawPeerKind::Message => PeerInputClass::ActionableMessage,
}
}
#[derive(Debug, Clone)]
struct PeerCommsFields {
trusted_peers: BTreeSet<PeerId>,
raw_item_peer: BTreeMap<RawItemId, PeerId>,
raw_item_kind: BTreeMap<RawItemId, RawPeerKind>,
classified_as: BTreeMap<RawItemId, PeerInputClass>,
text_projection: BTreeMap<RawItemId, String>,
content_shape: BTreeMap<RawItemId, ContentShape>,
request_id: BTreeMap<RawItemId, Option<RequestId>>,
reservation_key: BTreeMap<RawItemId, Option<ReservationKey>>,
trusted_snapshot: BTreeMap<RawItemId, bool>,
submission_queue: VecDeque<RawItemId>,
}
impl PeerCommsFields {
fn new() -> Self {
Self {
trusted_peers: BTreeSet::new(),
raw_item_peer: BTreeMap::new(),
raw_item_kind: BTreeMap::new(),
classified_as: BTreeMap::new(),
text_projection: BTreeMap::new(),
content_shape: BTreeMap::new(),
request_id: BTreeMap::new(),
reservation_key: BTreeMap::new(),
trusted_snapshot: BTreeMap::new(),
submission_queue: VecDeque::new(),
}
}
}
mod sealed {
pub trait Sealed {}
}
pub(crate) trait PeerCommsMutator: sealed::Sealed {
fn apply(&mut self, input: PeerCommsInput) -> Result<PeerCommsTransition, PeerCommsError>;
}
#[derive(Debug, Clone)]
pub(crate) struct PeerCommsAuthority {
phase: PeerIngressState,
fields: PeerCommsFields,
}
impl sealed::Sealed for PeerCommsAuthority {}
impl PeerCommsAuthority {
pub(crate) fn new() -> Self {
Self {
phase: PeerIngressState::Absent,
fields: PeerCommsFields::new(),
}
}
#[cfg(test)]
pub(crate) fn with_phase(phase: PeerIngressState) -> Self {
Self {
phase,
fields: PeerCommsFields::new(),
}
}
pub(crate) fn phase(&self) -> PeerIngressState {
self.phase
}
pub(crate) fn queue_len(&self) -> usize {
self.fields.submission_queue.len()
}
pub(crate) fn is_peer_trusted(&self, peer_id: &PeerId) -> bool {
self.fields.trusted_peers.contains(peer_id)
}
fn evaluate(
&self,
input: &PeerCommsInput,
) -> Result<(PeerIngressState, PeerCommsFields, Vec<PeerCommsEffect>), PeerCommsError> {
let phase = self.phase;
let mut fields = self.fields.clone();
let mut effects = Vec::new();
let next_phase = match input {
PeerCommsInput::TrustPeer { peer_id } => {
match phase {
PeerIngressState::Absent | PeerIngressState::Received => {}
_ => {
return Err(PeerCommsError::IllegalTransition {
from: phase,
reason: "TrustPeer only valid from Absent or Received".into(),
});
}
}
fields.trusted_peers.insert(peer_id.clone());
PeerIngressState::Absent
}
PeerCommsInput::ReceivePeerEnvelope {
raw_item_id,
peer_id,
raw_kind,
text_projection,
content_shape,
request_id,
reservation_key,
} => {
match phase {
PeerIngressState::Absent | PeerIngressState::Received => {}
_ => {
return Err(PeerCommsError::IllegalTransition {
from: phase,
reason: "ReceivePeerEnvelope only valid from Absent or Received".into(),
});
}
}
let trusted = fields.trusted_peers.contains(peer_id);
fields
.raw_item_peer
.insert(raw_item_id.clone(), peer_id.clone());
fields
.raw_item_kind
.insert(raw_item_id.clone(), raw_kind.clone());
fields
.text_projection
.insert(raw_item_id.clone(), text_projection.clone());
fields
.content_shape
.insert(raw_item_id.clone(), content_shape.clone());
fields
.request_id
.insert(raw_item_id.clone(), request_id.clone());
fields
.reservation_key
.insert(raw_item_id.clone(), reservation_key.clone());
if trusted {
fields
.classified_as
.insert(raw_item_id.clone(), class_for(raw_kind));
fields
.trusted_snapshot
.insert(raw_item_id.clone(), true);
fields.submission_queue.push_back(raw_item_id.clone());
PeerIngressState::Received
} else {
fields
.trusted_snapshot
.insert(raw_item_id.clone(), false);
PeerIngressState::Dropped
}
}
PeerCommsInput::SubmitTypedPeerInput { raw_item_id } => {
if phase != PeerIngressState::Received {
return Err(PeerCommsError::IllegalTransition {
from: phase,
reason: "SubmitTypedPeerInput only valid from Received".into(),
});
}
if !fields.submission_queue.contains(raw_item_id) {
return Err(PeerCommsError::GuardFailed {
from: phase,
guard: "item_was_queued".into(),
});
}
if !fields.classified_as.contains_key(raw_item_id) {
return Err(PeerCommsError::GuardFailed {
from: phase,
guard: "item_was_classified".into(),
});
}
let peer_input_class = fields
.classified_as
.get(raw_item_id)
.ok_or_else(|| PeerCommsError::MissingItemState(raw_item_id.clone()))?
.clone();
let text_projection = fields
.text_projection
.get(raw_item_id)
.ok_or_else(|| PeerCommsError::MissingItemState(raw_item_id.clone()))?
.clone();
let content_shape_val = fields
.content_shape
.get(raw_item_id)
.ok_or_else(|| PeerCommsError::MissingItemState(raw_item_id.clone()))?
.clone();
let request_id_val = fields
.request_id
.get(raw_item_id)
.ok_or_else(|| PeerCommsError::MissingItemState(raw_item_id.clone()))?
.clone();
let reservation_key_val = fields
.reservation_key
.get(raw_item_id)
.ok_or_else(|| PeerCommsError::MissingItemState(raw_item_id.clone()))?
.clone();
effects.push(PeerCommsEffect::SubmitPeerInputCandidate(
SubmitPeerInputCandidate {
raw_item_id: raw_item_id.clone(),
peer_input_class,
text_projection,
content_shape: content_shape_val,
request_id: request_id_val,
reservation_key: reservation_key_val,
},
));
fields
.submission_queue
.retain(|id| id != raw_item_id);
if fields.submission_queue.is_empty() {
PeerIngressState::Delivered
} else {
PeerIngressState::Received
}
}
};
Ok((next_phase, fields, effects))
}
}
impl PeerCommsMutator for PeerCommsAuthority {
fn apply(&mut self, input: PeerCommsInput) -> Result<PeerCommsTransition, PeerCommsError> {
let (next_phase, next_fields, effects) = self.evaluate(&input)?;
self.phase = next_phase;
self.fields = next_fields;
Ok(PeerCommsTransition {
next_phase,
effects,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn make_authority() -> PeerCommsAuthority {
PeerCommsAuthority::new()
}
fn peer(name: &str) -> PeerId {
PeerId(name.to_string())
}
fn item(name: &str) -> RawItemId {
RawItemId(name.to_string())
}
fn receive_envelope(
raw_item_id: RawItemId,
peer_id: PeerId,
raw_kind: RawPeerKind,
) -> PeerCommsInput {
PeerCommsInput::ReceivePeerEnvelope {
raw_item_id,
peer_id,
raw_kind,
text_projection: "test text".to_string(),
content_shape: ContentShape::Text,
request_id: None,
reservation_key: None,
}
}
#[test]
fn trust_peer_from_absent() {
let mut auth = make_authority();
let t = auth
.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.expect("trust from absent");
assert_eq!(t.next_phase, PeerIngressState::Absent);
assert!(t.effects.is_empty());
assert!(auth.is_peer_trusted(&peer("alice")));
}
#[test]
fn trust_peer_from_received() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
assert_eq!(auth.phase(), PeerIngressState::Received);
let t = auth
.apply(PeerCommsInput::TrustPeer {
peer_id: peer("bob"),
})
.expect("trust from received");
assert_eq!(t.next_phase, PeerIngressState::Absent);
assert!(auth.is_peer_trusted(&peer("bob")));
}
#[test]
fn trust_peer_from_dropped_is_rejected() {
let mut auth = PeerCommsAuthority::with_phase(PeerIngressState::Dropped);
let result = auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
});
assert!(result.is_err());
}
#[test]
fn trust_peer_from_delivered_is_rejected() {
let mut auth = PeerCommsAuthority::with_phase(PeerIngressState::Delivered);
let result = auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
});
assert!(result.is_err());
}
#[test]
fn receive_trusted_envelope_transitions_to_received() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
let t = auth
.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.expect("receive trusted");
assert_eq!(t.next_phase, PeerIngressState::Received);
assert!(t.effects.is_empty());
assert_eq!(auth.queue_len(), 1);
}
#[test]
fn receive_trusted_envelope_classifies_message() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
assert_eq!(
auth.fields.classified_as.get(&item("item-1")),
Some(&PeerInputClass::ActionableMessage)
);
}
#[test]
fn receive_trusted_envelope_classifies_request() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Request,
))
.unwrap();
assert_eq!(
auth.fields.classified_as.get(&item("item-1")),
Some(&PeerInputClass::ActionableRequest)
);
}
#[test]
fn receive_trusted_envelope_classifies_response_terminal() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::ResponseTerminal,
))
.unwrap();
assert_eq!(
auth.fields.classified_as.get(&item("item-1")),
Some(&PeerInputClass::Response)
);
}
#[test]
fn receive_trusted_envelope_classifies_plain_event() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::PlainEvent,
))
.unwrap();
assert_eq!(
auth.fields.classified_as.get(&item("item-1")),
Some(&PeerInputClass::PlainEvent)
);
}
#[test]
fn receive_trusted_envelope_classifies_silent_request() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::SilentRequest,
))
.unwrap();
assert_eq!(
auth.fields.classified_as.get(&item("item-1")),
Some(&PeerInputClass::SilentRequest)
);
}
#[test]
fn receive_multiple_trusted_envelopes_accumulates_queue() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
auth.apply(receive_envelope(
item("item-2"),
peer("alice"),
RawPeerKind::Request,
))
.unwrap();
assert_eq!(auth.phase(), PeerIngressState::Received);
assert_eq!(auth.queue_len(), 2);
}
#[test]
fn receive_untrusted_envelope_transitions_to_dropped() {
let mut auth = make_authority();
let t = auth
.apply(receive_envelope(
item("item-1"),
peer("untrusted"),
RawPeerKind::Message,
))
.expect("receive untrusted");
assert_eq!(t.next_phase, PeerIngressState::Dropped);
assert!(t.effects.is_empty());
assert_eq!(auth.queue_len(), 0);
}
#[test]
fn receive_untrusted_records_trusted_snapshot_false() {
let mut auth = make_authority();
auth.apply(receive_envelope(
item("item-1"),
peer("untrusted"),
RawPeerKind::Message,
))
.unwrap();
assert_eq!(
auth.fields.trusted_snapshot.get(&item("item-1")),
Some(&false)
);
}
#[test]
fn receive_untrusted_does_not_classify() {
let mut auth = make_authority();
auth.apply(receive_envelope(
item("item-1"),
peer("untrusted"),
RawPeerKind::Message,
))
.unwrap();
assert!(auth.fields.classified_as.get(&item("item-1")).is_none());
}
#[test]
fn receive_from_terminal_dropped_is_rejected() {
let mut auth = make_authority();
auth.apply(receive_envelope(
item("item-1"),
peer("untrusted"),
RawPeerKind::Message,
))
.unwrap();
assert_eq!(auth.phase(), PeerIngressState::Dropped);
let result = auth.apply(receive_envelope(
item("item-2"),
peer("untrusted"),
RawPeerKind::Message,
));
assert!(result.is_err());
}
#[test]
fn submit_single_item_drains_to_delivered() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
let t = auth
.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
})
.expect("submit");
assert_eq!(t.next_phase, PeerIngressState::Delivered);
assert_eq!(t.effects.len(), 1);
match &t.effects[0] {
PeerCommsEffect::SubmitPeerInputCandidate(effect) => {
assert_eq!(effect.raw_item_id, item("item-1"));
assert_eq!(effect.peer_input_class, PeerInputClass::ActionableMessage);
assert_eq!(effect.text_projection, "test text");
}
}
}
#[test]
fn submit_with_more_work_stays_received() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
auth.apply(receive_envelope(
item("item-2"),
peer("alice"),
RawPeerKind::Request,
))
.unwrap();
let t = auth
.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
})
.expect("submit first");
assert_eq!(t.next_phase, PeerIngressState::Received);
assert_eq!(auth.queue_len(), 1);
}
#[test]
fn submit_all_items_transitions_to_delivered() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
auth.apply(receive_envelope(
item("item-2"),
peer("alice"),
RawPeerKind::Request,
))
.unwrap();
auth.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
})
.unwrap();
let t = auth
.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-2"),
})
.expect("submit second");
assert_eq!(t.next_phase, PeerIngressState::Delivered);
assert_eq!(auth.queue_len(), 0);
}
#[test]
fn submit_from_absent_is_rejected() {
let mut auth = make_authority();
let result = auth.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
});
assert!(result.is_err());
}
#[test]
fn submit_unqueued_item_is_rejected() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
let result = auth.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-unknown"),
});
assert!(
result.is_err(),
"submit of unqueued item should be rejected"
);
}
#[test]
fn phase_unchanged_on_failed_transition() {
let mut auth = make_authority();
assert_eq!(auth.phase(), PeerIngressState::Absent);
let result = auth.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
});
assert!(result.is_err());
assert_eq!(auth.phase(), PeerIngressState::Absent);
}
#[test]
fn class_for_request() {
assert_eq!(class_for(&RawPeerKind::Request), PeerInputClass::ActionableRequest);
}
#[test]
fn class_for_response_terminal() {
assert_eq!(class_for(&RawPeerKind::ResponseTerminal), PeerInputClass::Response);
}
#[test]
fn class_for_response_progress() {
assert_eq!(class_for(&RawPeerKind::ResponseProgress), PeerInputClass::Response);
}
#[test]
fn class_for_plain_event() {
assert_eq!(class_for(&RawPeerKind::PlainEvent), PeerInputClass::PlainEvent);
}
#[test]
fn class_for_silent_request() {
assert_eq!(class_for(&RawPeerKind::SilentRequest), PeerInputClass::SilentRequest);
}
#[test]
fn class_for_message() {
assert_eq!(class_for(&RawPeerKind::Message), PeerInputClass::ActionableMessage);
}
#[test]
fn submit_effect_carries_correlation_fields() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(PeerCommsInput::ReceivePeerEnvelope {
raw_item_id: item("item-1"),
peer_id: peer("alice"),
raw_kind: RawPeerKind::Request,
text_projection: "hello world".to_string(),
content_shape: ContentShape::Blocks,
request_id: Some(RequestId("req-42".into())),
reservation_key: Some(ReservationKey("rsv-7".into())),
})
.unwrap();
let t = auth
.apply(PeerCommsInput::SubmitTypedPeerInput {
raw_item_id: item("item-1"),
})
.unwrap();
match &t.effects[0] {
PeerCommsEffect::SubmitPeerInputCandidate(effect) => {
assert_eq!(effect.peer_input_class, PeerInputClass::ActionableRequest);
assert_eq!(effect.text_projection, "hello world");
assert_eq!(effect.content_shape, ContentShape::Blocks);
assert_eq!(effect.request_id, Some(RequestId("req-42".into())));
assert_eq!(
effect.reservation_key,
Some(ReservationKey("rsv-7".into()))
);
}
}
}
#[test]
fn invariant_queued_items_are_classified() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
auth.apply(receive_envelope(
item("item-2"),
peer("alice"),
RawPeerKind::Request,
))
.unwrap();
for queued_id in &auth.fields.submission_queue {
assert!(
auth.fields.classified_as.contains_key(queued_id),
"queued item {queued_id:?} must have classification"
);
}
}
#[test]
fn invariant_queued_items_preserve_shape_and_text() {
let mut auth = make_authority();
auth.apply(PeerCommsInput::TrustPeer {
peer_id: peer("alice"),
})
.unwrap();
auth.apply(receive_envelope(
item("item-1"),
peer("alice"),
RawPeerKind::Message,
))
.unwrap();
for queued_id in &auth.fields.submission_queue {
assert!(
auth.fields.content_shape.contains_key(queued_id),
"queued item {queued_id:?} must have content_shape"
);
assert!(
auth.fields.text_projection.contains_key(queued_id),
"queued item {queued_id:?} must have text_projection"
);
assert!(
auth.fields.request_id.contains_key(queued_id),
"queued item {queued_id:?} must have request_id slot"
);
assert!(
auth.fields.reservation_key.contains_key(queued_id),
"queued item {queued_id:?} must have reservation_key slot"
);
}
}
}