feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -1,913 +1,2518 @@
|
||||
//! Replicated event history for Call to Play coordination.
|
||||
//! Direct, author-owned Call-to-Play state.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeSet, HashMap, HashSet},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
fmt,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
use crate::{
|
||||
PeerEvent,
|
||||
context::Ctx,
|
||||
events,
|
||||
network::send_call_to_play_events,
|
||||
services::{HandshakeCtx, perform_handshake_with_peer},
|
||||
use lanspread_proto::{
|
||||
CallId,
|
||||
CallNonce,
|
||||
CallToPlayAction,
|
||||
CallToPlayAuthorEvent,
|
||||
CallToPlayAuthorSnapshot,
|
||||
ControlValidationError,
|
||||
EventNonce,
|
||||
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
|
||||
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR,
|
||||
PeerId,
|
||||
RuntimeSessionId,
|
||||
};
|
||||
|
||||
const MAX_EVENTS: usize = 4_096;
|
||||
const MAX_ID_CHARS: usize = 128;
|
||||
const MAX_GAME_ID_CHARS: usize = 256;
|
||||
const MAX_USERNAME_CHARS: usize = 24;
|
||||
const MAX_MESSAGE_CHARS: usize = 500;
|
||||
use crate::peer_db::PeerEndpointGeneration;
|
||||
|
||||
pub(crate) const MAX_CALL_TO_PLAY_AUTHORS: usize = 64;
|
||||
pub(crate) const LOCAL_TERMINAL_EVENT_RESERVE: usize = 1;
|
||||
pub(crate) const LOCAL_TERMINAL_BYTE_RESERVE: usize = 512;
|
||||
|
||||
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
|
||||
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct CallToPlayStore {
|
||||
events: Vec<CallToPlayEvent>,
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayLocalIntent {
|
||||
pub(crate) call_id: Option<CallId>,
|
||||
pub(crate) action: CallToPlayLocalAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct BatchMerge {
|
||||
pub(crate) applied: Vec<CallToPlayEvent>,
|
||||
pub(crate) duplicates: usize,
|
||||
pub(crate) obsolete: usize,
|
||||
pub(crate) missing_call_ids: Vec<String>,
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CallToPlayLocalAction {
|
||||
Create {
|
||||
game_id: String,
|
||||
max_players: u16,
|
||||
scheduled_for: Option<i64>,
|
||||
deadline: i64,
|
||||
},
|
||||
Respond {
|
||||
ready_at: Option<i64>,
|
||||
},
|
||||
Rsvp,
|
||||
SendMessage {
|
||||
text: String,
|
||||
},
|
||||
Leave,
|
||||
Cancel,
|
||||
Start,
|
||||
AddTime {
|
||||
deadline: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl BatchMerge {
|
||||
pub(crate) fn needs_history(&self) -> bool {
|
||||
!self.missing_call_ids.is_empty()
|
||||
impl CallToPlayLocalAction {
|
||||
fn into_wire(self) -> CallToPlayAction {
|
||||
match self {
|
||||
Self::Create {
|
||||
game_id,
|
||||
max_players,
|
||||
scheduled_for,
|
||||
deadline,
|
||||
} => CallToPlayAction::Create {
|
||||
game_id,
|
||||
max_players,
|
||||
scheduled_for,
|
||||
deadline,
|
||||
},
|
||||
Self::Respond { ready_at } => CallToPlayAction::Respond { ready_at },
|
||||
Self::Rsvp => CallToPlayAction::Rsvp,
|
||||
Self::SendMessage { text } => CallToPlayAction::SendMessage { text },
|
||||
Self::Leave => CallToPlayAction::Leave,
|
||||
Self::Cancel => CallToPlayAction::Cancel,
|
||||
Self::Start => CallToPlayAction::Start,
|
||||
Self::AddTime { deadline } => CallToPlayAction::AddTime { deadline },
|
||||
}
|
||||
}
|
||||
|
||||
const fn is_create(&self) -> bool {
|
||||
matches!(self, Self::Create { .. })
|
||||
}
|
||||
|
||||
const fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Cancel | Self::Start)
|
||||
}
|
||||
|
||||
const fn requires_creator_authority(&self) -> bool {
|
||||
matches!(self, Self::Cancel | Self::Start | Self::AddTime { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum MergeError {
|
||||
Invalid(&'static str),
|
||||
ConflictingEvent(String),
|
||||
HistoryFull,
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayReceipt {
|
||||
pub(crate) call_id: CallId,
|
||||
pub(crate) event_id: EventNonce,
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
impl fmt::Display for MergeError {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayMutation {
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayPublication {
|
||||
pub(crate) view: CallToPlayView,
|
||||
pub(crate) local_revision: u64,
|
||||
pub(crate) local_changed: bool,
|
||||
}
|
||||
|
||||
/// A fallibility boundary captured before an atomic remote-state commit.
|
||||
///
|
||||
/// Preparation samples the clock and computes any fallible local-pruning
|
||||
/// candidate without mutating the store. Projection after a remote slice
|
||||
/// mutation is then infallible and uses this single time boundary.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PreparedCallToPlayPublication {
|
||||
now: i64,
|
||||
base_local_revision: u64,
|
||||
pruned_local: Option<CallToPlayAuthorSnapshot>,
|
||||
}
|
||||
|
||||
impl PreparedCallToPlayPublication {
|
||||
#[must_use]
|
||||
pub(crate) const fn local_changed(&self) -> bool {
|
||||
self.pruned_local.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayView {
|
||||
pub(crate) events: Vec<CallToPlayViewEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct CallToPlayViewEvent {
|
||||
pub(crate) id: EventNonce,
|
||||
pub(crate) call_id: CallId,
|
||||
pub(crate) author_id: PeerId,
|
||||
pub(crate) author_name: String,
|
||||
pub(crate) at: i64,
|
||||
pub(crate) action: CallToPlayAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct RemoteAuthorState {
|
||||
pub(crate) endpoint_generation: PeerEndpointGeneration,
|
||||
pub(crate) runtime_session_id: RuntimeSessionId,
|
||||
pub(crate) revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CallToPlayValidationError {
|
||||
Wire(ControlValidationError),
|
||||
SnapshotTooLarge {
|
||||
actual: usize,
|
||||
maximum: usize,
|
||||
},
|
||||
SnapshotEncoding,
|
||||
DuplicateEventId(EventNonce),
|
||||
DuplicateCreate(CallId),
|
||||
InvalidEvent {
|
||||
event_id: EventNonce,
|
||||
reason: &'static str,
|
||||
},
|
||||
UnauthorizedAction {
|
||||
event_id: EventNonce,
|
||||
call_id: CallId,
|
||||
},
|
||||
MissingCreatorRoot(CallId),
|
||||
NonMonotonicAuthorHistory,
|
||||
NonMonotonicCallHistory(CallId),
|
||||
ActionAfterTerminal(CallId),
|
||||
}
|
||||
|
||||
impl fmt::Display for CallToPlayValidationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Invalid(reason) => formatter.write_str(reason),
|
||||
Self::ConflictingEvent(event_id) => {
|
||||
write!(formatter, "conflicting Call to Play event ID {event_id}")
|
||||
Self::Wire(error) => write!(formatter, "{error}"),
|
||||
Self::SnapshotTooLarge { actual, maximum } => write!(
|
||||
formatter,
|
||||
"Call-to-Play author snapshot is {actual} bytes; maximum is {maximum}"
|
||||
),
|
||||
Self::SnapshotEncoding => {
|
||||
formatter.write_str("Call-to-Play author snapshot could not be encoded")
|
||||
}
|
||||
Self::DuplicateEventId(event_id) => {
|
||||
write!(formatter, "duplicate Call-to-Play event ID {event_id}")
|
||||
}
|
||||
Self::DuplicateCreate(call_id) => {
|
||||
write!(formatter, "duplicate Call-to-Play creator root {call_id}")
|
||||
}
|
||||
Self::InvalidEvent { event_id, reason } => {
|
||||
write!(formatter, "invalid Call-to-Play event {event_id}: {reason}")
|
||||
}
|
||||
Self::UnauthorizedAction { event_id, call_id } => write!(
|
||||
formatter,
|
||||
"Call-to-Play event {event_id} is not authoritative for {call_id}"
|
||||
),
|
||||
Self::MissingCreatorRoot(call_id) => {
|
||||
write!(formatter, "Call-to-Play call {call_id} has no creator root")
|
||||
}
|
||||
Self::NonMonotonicAuthorHistory => {
|
||||
formatter.write_str("Call-to-Play author events are not timestamp ordered")
|
||||
}
|
||||
Self::NonMonotonicCallHistory(call_id) => {
|
||||
write!(
|
||||
formatter,
|
||||
"Call-to-Play call {call_id} has non-monotonic history"
|
||||
)
|
||||
}
|
||||
Self::ActionAfterTerminal(call_id) => {
|
||||
write!(
|
||||
formatter,
|
||||
"Call-to-Play call {call_id} has an action after termination"
|
||||
)
|
||||
}
|
||||
Self::HistoryFull => formatter.write_str("Call to Play event history is full"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CallToPlayStore {
|
||||
pub(crate) fn snapshot(&mut self) -> Vec<CallToPlayEvent> {
|
||||
self.snapshot_at(now_ms())
|
||||
}
|
||||
impl std::error::Error for CallToPlayValidationError {}
|
||||
|
||||
fn snapshot_at(&mut self, now: i64) -> Vec<CallToPlayEvent> {
|
||||
compact_history(&mut self.events, now);
|
||||
self.events.clone()
|
||||
}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum CallToPlayMutationError {
|
||||
InvalidIntent(&'static str),
|
||||
UnknownOrExpiredCall(CallId),
|
||||
CreatorAuthorityRequired(CallId),
|
||||
CallAlreadyTerminal(CallId),
|
||||
EventHistoryFull,
|
||||
RevisionExhausted,
|
||||
ClockUnavailable,
|
||||
EntropyUnavailable,
|
||||
InvalidSnapshot(CallToPlayValidationError),
|
||||
}
|
||||
|
||||
pub(crate) fn merge_batch(
|
||||
&mut self,
|
||||
incoming: Vec<CallToPlayEvent>,
|
||||
) -> Result<BatchMerge, MergeError> {
|
||||
self.merge_batch_at(incoming, now_ms())
|
||||
}
|
||||
|
||||
fn merge_batch_at(
|
||||
&mut self,
|
||||
incoming: Vec<CallToPlayEvent>,
|
||||
now: i64,
|
||||
) -> Result<BatchMerge, MergeError> {
|
||||
for event in &incoming {
|
||||
validate_event(event).map_err(MergeError::Invalid)?;
|
||||
}
|
||||
|
||||
let (incoming, mut duplicates) = deduplicate_batch(incoming)?;
|
||||
|
||||
// Expiry is evaluated before accepting new actions. An action cannot keep
|
||||
// an already-retired root alive by itself; the sender must provide the
|
||||
// complete history in the same batch.
|
||||
let mut retained = self.events.clone();
|
||||
compact_history(&mut retained, now);
|
||||
|
||||
let retained_by_id = retained
|
||||
.iter()
|
||||
.map(|event| (event.id.as_str(), event))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut new_events = Vec::with_capacity(incoming.len());
|
||||
for event in incoming {
|
||||
if let Some(existing) = retained_by_id.get(event.id.as_str()) {
|
||||
if *existing != &event {
|
||||
return Err(MergeError::ConflictingEvent(event.id));
|
||||
}
|
||||
duplicates += 1;
|
||||
} else {
|
||||
new_events.push(event);
|
||||
impl fmt::Display for CallToPlayMutationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidIntent(reason) => formatter.write_str(reason),
|
||||
Self::UnknownOrExpiredCall(call_id) => {
|
||||
write!(
|
||||
formatter,
|
||||
"Call-to-Play call {call_id} is unknown or expired"
|
||||
)
|
||||
}
|
||||
}
|
||||
drop(retained_by_id);
|
||||
|
||||
let rooted_calls = retained
|
||||
.iter()
|
||||
.chain(&new_events)
|
||||
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
|
||||
.map(|event| event.call_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let retained_tombstones = terminal_tombstone_call_ids(&retained);
|
||||
let retained_history = HistoryIndex::build(&retained);
|
||||
let mut applicable = Vec::with_capacity(new_events.len());
|
||||
let mut missing_call_ids = BTreeSet::new();
|
||||
let mut obsolete = 0;
|
||||
|
||||
for event in new_events {
|
||||
if retained_tombstones.contains(event.call_id.as_str()) {
|
||||
obsolete += 1;
|
||||
continue;
|
||||
Self::CreatorAuthorityRequired(call_id) => {
|
||||
write!(formatter, "creator authority is required for {call_id}")
|
||||
}
|
||||
if let Some(terminal) = retained_history.terminal_events.get(&event.call_id)
|
||||
&& (matches!(event.action, CallToPlayAction::Create { .. })
|
||||
|| (event.at, event.id.as_str()) > terminal.order_key())
|
||||
{
|
||||
obsolete += 1;
|
||||
continue;
|
||||
Self::CallAlreadyTerminal(call_id) => {
|
||||
write!(formatter, "Call-to-Play call {call_id} is already terminal")
|
||||
}
|
||||
if !matches!(event.action, CallToPlayAction::Create { .. })
|
||||
&& !rooted_calls.contains(event.call_id.as_str())
|
||||
{
|
||||
missing_call_ids.insert(event.call_id);
|
||||
continue;
|
||||
Self::EventHistoryFull => {
|
||||
formatter.write_str("Call-to-Play local event history is full")
|
||||
}
|
||||
applicable.push(event);
|
||||
Self::RevisionExhausted => {
|
||||
formatter.write_str("Call-to-Play local revision is exhausted")
|
||||
}
|
||||
Self::ClockUnavailable => formatter.write_str("system clock is unavailable"),
|
||||
Self::EntropyUnavailable => {
|
||||
formatter.write_str("secure random number generation is unavailable")
|
||||
}
|
||||
Self::InvalidSnapshot(error) => write!(formatter, "{error}"),
|
||||
}
|
||||
|
||||
let applicable_ids = applicable
|
||||
.iter()
|
||||
.map(|event| event.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
retained.extend(applicable);
|
||||
compact_history(&mut retained, now);
|
||||
|
||||
if unresolved_event_count(&retained) > MAX_EVENTS {
|
||||
return Err(MergeError::HistoryFull);
|
||||
}
|
||||
|
||||
let retained_ids = retained
|
||||
.iter()
|
||||
.map(|event| event.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let applied = retained
|
||||
.iter()
|
||||
.filter(|event| applicable_ids.contains(&event.id))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
obsolete += applicable_ids
|
||||
.iter()
|
||||
.filter(|event_id| !retained_ids.contains(*event_id))
|
||||
.count();
|
||||
|
||||
self.events = retained;
|
||||
Ok(BatchMerge {
|
||||
applied,
|
||||
duplicates,
|
||||
obsolete,
|
||||
missing_call_ids: missing_call_ids.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn deduplicate_batch(
|
||||
incoming: Vec<CallToPlayEvent>,
|
||||
) -> Result<(Vec<CallToPlayEvent>, usize), MergeError> {
|
||||
let mut unique = Vec::<CallToPlayEvent>::with_capacity(incoming.len());
|
||||
let mut indexes = HashMap::<String, usize>::with_capacity(incoming.len());
|
||||
let mut duplicates = 0;
|
||||
impl std::error::Error for CallToPlayMutationError {}
|
||||
|
||||
for event in incoming {
|
||||
if let Some(index) = indexes.get(&event.id).copied() {
|
||||
if unique[index] != event {
|
||||
return Err(MergeError::ConflictingEvent(event.id));
|
||||
}
|
||||
duplicates += 1;
|
||||
} else {
|
||||
indexes.insert(event.id.clone(), unique.len());
|
||||
unique.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((unique, duplicates))
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PreparedRemoteAuthor {
|
||||
author_id: PeerId,
|
||||
endpoint_generation: PeerEndpointGeneration,
|
||||
runtime_session_id: RuntimeSessionId,
|
||||
candidate: PreparedRemoteCandidate,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HistoryIndex {
|
||||
creators: HashMap<String, CreateRecord>,
|
||||
terminal_events: HashMap<String, EventRecord>,
|
||||
extensions: HashMap<String, ExtensionRecord>,
|
||||
enum PreparedRemoteCandidate {
|
||||
Valid(CallToPlayAuthorSnapshot),
|
||||
Invalid(CallToPlayValidationError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CreateRecord {
|
||||
at: i64,
|
||||
event_id: String,
|
||||
actor_id: String,
|
||||
deadline: i64,
|
||||
impl PreparedRemoteAuthor {
|
||||
/// Performs all allocation, encoding, and semantic validation without a
|
||||
/// store or peer-database lock. The invalid candidate is retained so the
|
||||
/// locked observation can apply session-clearing rules atomically.
|
||||
pub(crate) fn prepare(
|
||||
author_id: PeerId,
|
||||
endpoint_generation: PeerEndpointGeneration,
|
||||
runtime_session_id: RuntimeSessionId,
|
||||
snapshot: CallToPlayAuthorSnapshot,
|
||||
) -> Self {
|
||||
let candidate = match validate_author_snapshot(author_id, &snapshot) {
|
||||
Ok(()) => PreparedRemoteCandidate::Valid(snapshot),
|
||||
Err(error) => PreparedRemoteCandidate::Invalid(error),
|
||||
};
|
||||
Self {
|
||||
author_id,
|
||||
endpoint_generation,
|
||||
runtime_session_id,
|
||||
candidate,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn validation_error(&self) -> Option<&CallToPlayValidationError> {
|
||||
match &self.candidate {
|
||||
PreparedRemoteCandidate::Valid(_) => None,
|
||||
PreparedRemoteCandidate::Invalid(error) => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateRecord {
|
||||
fn order_key(&self) -> (i64, &str) {
|
||||
(self.at, &self.event_id)
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ObserveRemoteAuthorOutcome {
|
||||
Applied {
|
||||
session_changed: bool,
|
||||
},
|
||||
Unchanged {
|
||||
generation_rebound: bool,
|
||||
},
|
||||
IgnoredStale {
|
||||
generation_rebound: bool,
|
||||
},
|
||||
EqualRevisionConflict {
|
||||
generation_rebound: bool,
|
||||
},
|
||||
InvalidCleared(CallToPlayValidationError),
|
||||
InvalidPreserved {
|
||||
error: CallToPlayValidationError,
|
||||
generation_rebound: bool,
|
||||
},
|
||||
InvalidAbsent(CallToPlayValidationError),
|
||||
AtCapacity,
|
||||
RejectedLocalIdentity,
|
||||
}
|
||||
|
||||
impl ObserveRemoteAuthorOutcome {
|
||||
#[must_use]
|
||||
pub(crate) const fn view_changed(&self) -> bool {
|
||||
matches!(self, Self::Applied { .. } | Self::InvalidCleared(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EventRecord {
|
||||
at: i64,
|
||||
event_id: String,
|
||||
struct RemoteAuthorSlice {
|
||||
endpoint_generation: PeerEndpointGeneration,
|
||||
runtime_session_id: RuntimeSessionId,
|
||||
snapshot: CallToPlayAuthorSnapshot,
|
||||
}
|
||||
|
||||
impl EventRecord {
|
||||
fn order_key(&self) -> (i64, &str) {
|
||||
(self.at, &self.event_id)
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CallToPlayStore {
|
||||
local_peer_id: PeerId,
|
||||
local: CallToPlayAuthorSnapshot,
|
||||
remote: BTreeMap<PeerId, RemoteAuthorSlice>,
|
||||
last_publication_at: i64,
|
||||
#[cfg(test)]
|
||||
projection_count: usize,
|
||||
}
|
||||
|
||||
impl CallToPlayStore {
|
||||
pub(crate) fn new(
|
||||
local_peer_id: PeerId,
|
||||
_runtime_session_id: RuntimeSessionId,
|
||||
display_name: String,
|
||||
) -> Result<Self, CallToPlayMutationError> {
|
||||
let local = CallToPlayAuthorSnapshot {
|
||||
revision: 0,
|
||||
display_name,
|
||||
events: Vec::new(),
|
||||
};
|
||||
validate_author_snapshot(local_peer_id, &local)
|
||||
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
||||
ensure_local_terminal_reserve(&local, false)?;
|
||||
Ok(Self {
|
||||
local_peer_id,
|
||||
local,
|
||||
remote: BTreeMap::new(),
|
||||
last_publication_at: 0,
|
||||
#[cfg(test)]
|
||||
projection_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ExtensionRecord {
|
||||
event: EventRecord,
|
||||
deadline: i64,
|
||||
}
|
||||
pub(crate) fn publish_local(
|
||||
&mut self,
|
||||
intent: CallToPlayLocalIntent,
|
||||
display_name: String,
|
||||
) -> Result<(CallToPlayReceipt, CallToPlayPublication), CallToPlayMutationError> {
|
||||
let now = now_ms()?.max(self.last_publication_at);
|
||||
let event_nonce = EventNonce::from_bytes(random_nonce_bytes()?);
|
||||
let call_nonce = intent
|
||||
.action
|
||||
.is_create()
|
||||
.then(|| random_nonce_bytes().map(CallNonce::from_bytes))
|
||||
.transpose()?;
|
||||
let receipt = self.publish_local_at(intent, display_name, now, call_nonce, event_nonce)?;
|
||||
Ok((receipt, self.project_publication_at(now, true)))
|
||||
}
|
||||
|
||||
impl HistoryIndex {
|
||||
fn build(events: &[CallToPlayEvent]) -> Self {
|
||||
let mut creators = HashMap::<String, CreateRecord>::new();
|
||||
for event in events {
|
||||
let CallToPlayAction::Create { deadline, .. } = event.action else {
|
||||
continue;
|
||||
};
|
||||
let candidate = CreateRecord {
|
||||
at: event.at,
|
||||
event_id: event.id.clone(),
|
||||
actor_id: event.actor_id.clone(),
|
||||
deadline,
|
||||
};
|
||||
creators
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if candidate.order_key() < current.order_key() {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
pub(crate) fn set_local_display_name(
|
||||
&mut self,
|
||||
display_name: String,
|
||||
) -> Result<(Option<CallToPlayMutation>, CallToPlayPublication), CallToPlayMutationError> {
|
||||
let now = now_ms()?.max(self.last_publication_at);
|
||||
let mutation = self.set_local_display_name_at(display_name, now)?;
|
||||
let publication = self.project_publication_at(now, mutation.is_some());
|
||||
Ok((mutation, publication))
|
||||
}
|
||||
|
||||
pub(crate) fn current_publication(
|
||||
&mut self,
|
||||
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
|
||||
let now = now_ms()?.max(self.last_publication_at);
|
||||
self.publication_at(now)
|
||||
}
|
||||
|
||||
pub(crate) fn current_responder_state(
|
||||
&mut self,
|
||||
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
|
||||
let now = now_ms()?.max(self.last_publication_at);
|
||||
self.responder_state_at(now)
|
||||
}
|
||||
|
||||
pub(crate) fn local_responder_snapshot(
|
||||
&mut self,
|
||||
) -> Result<
|
||||
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
|
||||
CallToPlayMutationError,
|
||||
> {
|
||||
let now = now_ms()?.max(self.last_publication_at);
|
||||
self.local_responder_snapshot_at(now)
|
||||
}
|
||||
|
||||
fn local_responder_snapshot_at(
|
||||
&mut self,
|
||||
now: i64,
|
||||
) -> Result<
|
||||
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
|
||||
CallToPlayMutationError,
|
||||
> {
|
||||
let (revision, publication) = self.responder_state_at(now)?;
|
||||
Ok((self.local.clone(), revision, publication))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_publication(
|
||||
&self,
|
||||
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
|
||||
self.prepare_publication_at(now_ms()?.max(self.last_publication_at))
|
||||
}
|
||||
|
||||
fn prepare_publication_at(
|
||||
&self,
|
||||
now: i64,
|
||||
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
|
||||
Ok(PreparedCallToPlayPublication {
|
||||
now,
|
||||
base_local_revision: self.local.revision,
|
||||
pruned_local: self.pruned_local_candidate_at(now)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn view_from_prepared(
|
||||
&mut self,
|
||||
prepared: PreparedCallToPlayPublication,
|
||||
) -> CallToPlayPublication {
|
||||
let PreparedCallToPlayPublication {
|
||||
mut now,
|
||||
base_local_revision,
|
||||
pruned_local,
|
||||
} = prepared;
|
||||
now = now.max(self.last_publication_at);
|
||||
let local_changed = if self.local.revision == base_local_revision {
|
||||
if let Some(pruned_local) = pruned_local {
|
||||
self.local = pruned_local;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
self.project_publication_at(now, local_changed)
|
||||
}
|
||||
|
||||
/// Commits a prepared candidate while the caller holds the peer-database
|
||||
/// write lock before this store's write lock and has rechecked the pinned
|
||||
/// endpoint generation.
|
||||
pub(crate) fn observe_prepared_remote(
|
||||
&mut self,
|
||||
prepared: PreparedRemoteAuthor,
|
||||
) -> ObserveRemoteAuthorOutcome {
|
||||
let PreparedRemoteAuthor {
|
||||
author_id,
|
||||
endpoint_generation,
|
||||
runtime_session_id,
|
||||
candidate,
|
||||
} = prepared;
|
||||
|
||||
if author_id == self.local_peer_id {
|
||||
return ObserveRemoteAuthorOutcome::RejectedLocalIdentity;
|
||||
}
|
||||
|
||||
let mut terminal_events = HashMap::<String, EventRecord>::new();
|
||||
let mut extensions = HashMap::<String, ExtensionRecord>::new();
|
||||
for event in events {
|
||||
let Some(creator) = creators.get(&event.call_id) else {
|
||||
let snapshot = match candidate {
|
||||
PreparedRemoteCandidate::Valid(snapshot) => snapshot,
|
||||
PreparedRemoteCandidate::Invalid(error) => {
|
||||
let Some(current) = self.remote.get_mut(&author_id) else {
|
||||
return ObserveRemoteAuthorOutcome::InvalidAbsent(error);
|
||||
};
|
||||
if current.runtime_session_id != runtime_session_id {
|
||||
self.remote.remove(&author_id);
|
||||
return ObserveRemoteAuthorOutcome::InvalidCleared(error);
|
||||
}
|
||||
let generation_rebound = current.endpoint_generation != endpoint_generation;
|
||||
current.endpoint_generation = endpoint_generation;
|
||||
return ObserveRemoteAuthorOutcome::InvalidPreserved {
|
||||
error,
|
||||
generation_rebound,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(current) = self.remote.get_mut(&author_id) {
|
||||
let session_changed = current.runtime_session_id != runtime_session_id;
|
||||
if session_changed || snapshot.revision > current.snapshot.revision {
|
||||
*current = RemoteAuthorSlice {
|
||||
endpoint_generation,
|
||||
runtime_session_id,
|
||||
snapshot,
|
||||
};
|
||||
return ObserveRemoteAuthorOutcome::Applied { session_changed };
|
||||
}
|
||||
|
||||
let generation_rebound = current.endpoint_generation != endpoint_generation;
|
||||
current.endpoint_generation = endpoint_generation;
|
||||
if snapshot.revision < current.snapshot.revision {
|
||||
return ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound };
|
||||
}
|
||||
return if snapshot == current.snapshot {
|
||||
ObserveRemoteAuthorOutcome::Unchanged { generation_rebound }
|
||||
} else {
|
||||
ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound }
|
||||
};
|
||||
}
|
||||
|
||||
if self.remote.len() >= MAX_CALL_TO_PLAY_AUTHORS - 1 {
|
||||
return ObserveRemoteAuthorOutcome::AtCapacity;
|
||||
}
|
||||
self.remote.insert(
|
||||
author_id,
|
||||
RemoteAuthorSlice {
|
||||
endpoint_generation,
|
||||
runtime_session_id,
|
||||
snapshot,
|
||||
},
|
||||
);
|
||||
ObserveRemoteAuthorOutcome::Applied {
|
||||
session_changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears every remote author and returns the resulting full, local-only
|
||||
/// publication.
|
||||
///
|
||||
/// The operation is infallible: a failed clock sample or normal local
|
||||
/// prune is returned as the optional diagnostic after the remote slices
|
||||
/// have still been cleared and projected. Such a failure preserves the
|
||||
/// local author exactly. On success, the local revision changes only when
|
||||
/// ordinary retention pruning requires it.
|
||||
#[must_use = "the replacement publication and any maintenance diagnostic must be handled"]
|
||||
pub(crate) fn clear_remote_authors_and_project(
|
||||
&mut self,
|
||||
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
|
||||
self.clear_remote_authors_and_project_at(now_ms())
|
||||
}
|
||||
|
||||
fn clear_remote_authors_and_project_at(
|
||||
&mut self,
|
||||
now: Result<i64, CallToPlayMutationError>,
|
||||
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
|
||||
let now = match now {
|
||||
Ok(now) => now.max(self.last_publication_at),
|
||||
Err(error) => {
|
||||
self.remote.clear();
|
||||
let fallback_now = self.last_publication_at;
|
||||
let publication = self.project_publication_at(fallback_now, false);
|
||||
return (publication, Some(error));
|
||||
}
|
||||
};
|
||||
let pruned_local = self.pruned_local_candidate_at(now);
|
||||
self.remote.clear();
|
||||
match pruned_local {
|
||||
Ok(pruned_local) => {
|
||||
let local_changed = pruned_local.is_some();
|
||||
if let Some(pruned_local) = pruned_local {
|
||||
self.local = pruned_local;
|
||||
}
|
||||
(self.project_publication_at(now, local_changed), None)
|
||||
}
|
||||
Err(error) => (self.project_publication_at(now, false), Some(error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_remote_author_if_generation(
|
||||
&mut self,
|
||||
author_id: PeerId,
|
||||
endpoint_generation: PeerEndpointGeneration,
|
||||
) -> bool {
|
||||
if self
|
||||
.remote
|
||||
.get(&author_id)
|
||||
.is_none_or(|slice| slice.endpoint_generation != endpoint_generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.remote.remove(&author_id).is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn remote_author_state(&self, author_id: PeerId) -> Option<RemoteAuthorState> {
|
||||
self.remote.get(&author_id).map(|slice| RemoteAuthorState {
|
||||
endpoint_generation: slice.endpoint_generation,
|
||||
runtime_session_id: slice.runtime_session_id,
|
||||
revision: slice.snapshot.revision,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
pub(crate) fn remote_author_count(&self) -> usize {
|
||||
self.remote.len()
|
||||
}
|
||||
|
||||
fn publish_local_at(
|
||||
&mut self,
|
||||
intent: CallToPlayLocalIntent,
|
||||
display_name: String,
|
||||
now: i64,
|
||||
call_nonce: Option<CallNonce>,
|
||||
event_nonce: EventNonce,
|
||||
) -> Result<CallToPlayReceipt, CallToPlayMutationError> {
|
||||
if now <= 0 {
|
||||
return Err(CallToPlayMutationError::ClockUnavailable);
|
||||
}
|
||||
|
||||
let CallToPlayLocalIntent { call_id, action } = intent;
|
||||
let is_create = action.is_create();
|
||||
let is_terminal = action.is_terminal();
|
||||
let requires_creator_authority = action.requires_creator_authority();
|
||||
let call_id = if is_create {
|
||||
if call_id.is_some() {
|
||||
return Err(CallToPlayMutationError::InvalidIntent(
|
||||
"Create must not supply a call ID",
|
||||
));
|
||||
}
|
||||
CallId::new(
|
||||
self.local_peer_id,
|
||||
call_nonce.ok_or(CallToPlayMutationError::EntropyUnavailable)?,
|
||||
)
|
||||
} else {
|
||||
call_id.ok_or(CallToPlayMutationError::InvalidIntent(
|
||||
"non-Create action requires a call ID",
|
||||
))?
|
||||
};
|
||||
|
||||
if requires_creator_authority && call_id.creator != self.local_peer_id {
|
||||
return Err(CallToPlayMutationError::CreatorAuthorityRequired(call_id));
|
||||
}
|
||||
|
||||
let retained_events = self.retained_local_events_at(now);
|
||||
let event_at = retained_events
|
||||
.last()
|
||||
.map_or(now, |event| now.max(event.at));
|
||||
if !is_create {
|
||||
let Some(window) = self.call_window_at(call_id, now, &retained_events) else {
|
||||
return Err(CallToPlayMutationError::UnknownOrExpiredCall(call_id));
|
||||
};
|
||||
if window.terminal_at.is_some() {
|
||||
return Err(CallToPlayMutationError::CallAlreadyTerminal(call_id));
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidate = CallToPlayAuthorSnapshot {
|
||||
revision: self
|
||||
.local
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
||||
display_name,
|
||||
events: retained_events,
|
||||
};
|
||||
candidate.events.push(CallToPlayAuthorEvent {
|
||||
id: event_nonce,
|
||||
call_id,
|
||||
at: event_at,
|
||||
action: action.into_wire(),
|
||||
});
|
||||
validate_author_snapshot(self.local_peer_id, &candidate)
|
||||
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
||||
ensure_local_terminal_reserve(&candidate, is_terminal)?;
|
||||
|
||||
self.local = candidate;
|
||||
Ok(CallToPlayReceipt {
|
||||
call_id,
|
||||
event_id: event_nonce,
|
||||
revision: self.local.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_local_display_name_at(
|
||||
&mut self,
|
||||
display_name: String,
|
||||
now: i64,
|
||||
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
|
||||
let retained_events = self.retained_local_events_at(now);
|
||||
if display_name == self.local.display_name && retained_events == self.local.events {
|
||||
return Ok(None);
|
||||
}
|
||||
let candidate = CallToPlayAuthorSnapshot {
|
||||
revision: self
|
||||
.local
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
||||
display_name,
|
||||
events: retained_events,
|
||||
};
|
||||
validate_author_snapshot(self.local_peer_id, &candidate)
|
||||
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
||||
self.local = candidate;
|
||||
Ok(Some(CallToPlayMutation {
|
||||
revision: self.local.revision,
|
||||
}))
|
||||
}
|
||||
|
||||
fn prune_local_at(
|
||||
&mut self,
|
||||
now: i64,
|
||||
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
|
||||
let Some(candidate) = self.pruned_local_candidate_at(now)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.local = candidate;
|
||||
Ok(Some(CallToPlayMutation {
|
||||
revision: self.local.revision,
|
||||
}))
|
||||
}
|
||||
|
||||
fn pruned_local_candidate_at(
|
||||
&self,
|
||||
now: i64,
|
||||
) -> Result<Option<CallToPlayAuthorSnapshot>, CallToPlayMutationError> {
|
||||
let expired = self.expired_local_call_ids_at(now);
|
||||
if expired.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let retained_events = self
|
||||
.local
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| !expired.contains(&event.call_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
let candidate = CallToPlayAuthorSnapshot {
|
||||
revision: self
|
||||
.local
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
|
||||
display_name: self.local.display_name.clone(),
|
||||
events: retained_events,
|
||||
};
|
||||
validate_author_snapshot(self.local_peer_id, &candidate)
|
||||
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
|
||||
Ok(Some(candidate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn local_snapshot_at(
|
||||
&mut self,
|
||||
now: i64,
|
||||
) -> Result<CallToPlayAuthorSnapshot, CallToPlayMutationError> {
|
||||
self.prune_local_at(now)?;
|
||||
Ok(self.local.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn view_at(&mut self, now: i64) -> Result<CallToPlayView, CallToPlayMutationError> {
|
||||
Ok(self.publication_at(now)?.view)
|
||||
}
|
||||
|
||||
fn publication_at(
|
||||
&mut self,
|
||||
now: i64,
|
||||
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
|
||||
let local_changed = self.prune_local_at(now)?.is_some();
|
||||
Ok(self.project_publication_at(now, local_changed))
|
||||
}
|
||||
|
||||
fn responder_state_at(
|
||||
&mut self,
|
||||
now: i64,
|
||||
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
|
||||
let local_changed = self.prune_local_at(now)?.is_some();
|
||||
if !local_changed {
|
||||
return Ok((self.local.revision, None));
|
||||
}
|
||||
let publication = self.project_publication_at(now, true);
|
||||
Ok((publication.local_revision, Some(publication)))
|
||||
}
|
||||
|
||||
fn project_publication_at(&mut self, now: i64, local_changed: bool) -> CallToPlayPublication {
|
||||
let now = now.max(self.last_publication_at);
|
||||
self.last_publication_at = now;
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.projection_count += 1;
|
||||
}
|
||||
CallToPlayPublication {
|
||||
view: self.project_view_at(now),
|
||||
local_revision: self.local.revision,
|
||||
local_changed,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_view_at(&self, now: i64) -> CallToPlayView {
|
||||
let windows = self.call_windows();
|
||||
let mut events = Vec::new();
|
||||
Self::extend_visible_author_events(
|
||||
self.local_peer_id,
|
||||
&self.local,
|
||||
now,
|
||||
&windows,
|
||||
&mut events,
|
||||
);
|
||||
for (author_id, slice) in &self.remote {
|
||||
Self::extend_visible_author_events(
|
||||
*author_id,
|
||||
&slice.snapshot,
|
||||
now,
|
||||
&windows,
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
events.sort_by_key(|event| (event.at, event.call_id, event.author_id, event.id));
|
||||
CallToPlayView { events }
|
||||
}
|
||||
|
||||
fn extend_visible_author_events(
|
||||
author_id: PeerId,
|
||||
snapshot: &CallToPlayAuthorSnapshot,
|
||||
now: i64,
|
||||
windows: &HashMap<CallId, CallWindow>,
|
||||
output: &mut Vec<CallToPlayViewEvent>,
|
||||
) {
|
||||
for event in &snapshot.events {
|
||||
let Some(window) = windows.get(&event.call_id) else {
|
||||
continue;
|
||||
};
|
||||
if event.actor_id != creator.actor_id
|
||||
|| (event.at, event.id.as_str()) <= creator.order_key()
|
||||
if !window.is_visible_at(now)
|
||||
|| event.at < window.created_at
|
||||
|| window
|
||||
.terminal_at
|
||||
.is_some_and(|terminal_at| event.at > terminal_at)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
event.action,
|
||||
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||
) {
|
||||
let candidate = EventRecord {
|
||||
at: event.at,
|
||||
event_id: event.id.clone(),
|
||||
};
|
||||
terminal_events
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if candidate.order_key() < current.order_key() {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
} else if let CallToPlayAction::AddTime { deadline } = event.action {
|
||||
let candidate = ExtensionRecord {
|
||||
event: EventRecord {
|
||||
at: event.at,
|
||||
event_id: event.id.clone(),
|
||||
},
|
||||
deadline,
|
||||
};
|
||||
extensions
|
||||
.entry(event.call_id.clone())
|
||||
.and_modify(|current| {
|
||||
if candidate.event.order_key() > current.event.order_key() {
|
||||
current.clone_from(&candidate);
|
||||
}
|
||||
})
|
||||
.or_insert(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
creators,
|
||||
terminal_events,
|
||||
extensions,
|
||||
output.push(CallToPlayViewEvent {
|
||||
id: event.id,
|
||||
call_id: event.call_id,
|
||||
author_id,
|
||||
author_name: snapshot.display_name.clone(),
|
||||
at: event.at,
|
||||
action: event.action.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn expired_call_ids(&self, now: i64) -> HashSet<&str> {
|
||||
self.creators
|
||||
fn retained_local_events_at(&self, now: i64) -> Vec<CallToPlayAuthorEvent> {
|
||||
let expired = self.expired_local_call_ids_at(now);
|
||||
self.local
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|(call_id, creator)| {
|
||||
if self.terminal_events.contains_key(call_id) {
|
||||
return None;
|
||||
}
|
||||
let deadline = self
|
||||
.extensions
|
||||
.get(call_id)
|
||||
.map_or(creator.deadline, |extension| extension.deadline);
|
||||
(now - deadline > EXPIRED_RETENTION_MS).then_some(call_id.as_str())
|
||||
.filter(|event| !expired.contains(&event.call_id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn expired_local_call_ids_at(&self, now: i64) -> HashSet<CallId> {
|
||||
let local_call_ids = self
|
||||
.local
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.call_id)
|
||||
.collect::<HashSet<_>>();
|
||||
if local_call_ids.is_empty() {
|
||||
return HashSet::new();
|
||||
}
|
||||
self.call_windows()
|
||||
.into_iter()
|
||||
.filter_map(|(call_id, window)| {
|
||||
(local_call_ids.contains(&call_id) && !window.is_visible_at(now)).then_some(call_id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_history(events: &mut Vec<CallToPlayEvent>, now: i64) {
|
||||
let index = HistoryIndex::build(events);
|
||||
let expired_calls = index.expired_call_ids(now);
|
||||
|
||||
events.retain(|event| {
|
||||
if expired_calls.contains(event.call_id.as_str()) {
|
||||
return false;
|
||||
}
|
||||
let Some(terminal) = index.terminal_events.get(&event.call_id) else {
|
||||
return true;
|
||||
};
|
||||
if (event.at, event.id.as_str()) > terminal.order_key() {
|
||||
return false;
|
||||
}
|
||||
now - terminal.at <= TERMINAL_RETENTION_MS || event.id == terminal.event_id
|
||||
});
|
||||
}
|
||||
|
||||
fn terminal_tombstone_call_ids(events: &[CallToPlayEvent]) -> HashSet<String> {
|
||||
let rooted_calls = events
|
||||
.iter()
|
||||
.filter(|event| matches!(event.action, CallToPlayAction::Create { .. }))
|
||||
.map(|event| event.call_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
matches!(
|
||||
event.action,
|
||||
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||
) && !rooted_calls.contains(event.call_id.as_str())
|
||||
})
|
||||
.map(|event| event.call_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unresolved_event_count(events: &[CallToPlayEvent]) -> usize {
|
||||
let index = HistoryIndex::build(events);
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
index.creators.contains_key(&event.call_id)
|
||||
&& !index.terminal_events.contains_key(&event.call_id)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
i64::try_from(
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
)
|
||||
.unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
pub(crate) async fn publish(
|
||||
ctx: &Ctx,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
mut event: CallToPlayEvent,
|
||||
) -> Result<(), String> {
|
||||
event.actor_id.clone_from(ctx.peer_id.as_ref());
|
||||
let merged = ctx
|
||||
.call_to_play
|
||||
.write()
|
||||
.await
|
||||
.merge_batch(vec![event.clone()])
|
||||
.map_err(|err| err.to_string())?;
|
||||
if merged.needs_history() {
|
||||
return Err("Call to Play history is missing".to_string());
|
||||
}
|
||||
if merged.applied.is_empty() {
|
||||
if merged.obsolete > 0 {
|
||||
return Err("Call to Play event is obsolete".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
fn call_window_at(
|
||||
&self,
|
||||
call_id: CallId,
|
||||
now: i64,
|
||||
local_events: &[CallToPlayAuthorEvent],
|
||||
) -> Option<CallWindow> {
|
||||
let window = if call_id.creator == self.local_peer_id {
|
||||
call_window_from_creator_events(call_id, local_events)
|
||||
} else {
|
||||
self.remote
|
||||
.get(&call_id.creator)
|
||||
.and_then(|slice| call_window_from_creator_events(call_id, &slice.snapshot.events))
|
||||
}?;
|
||||
window.is_visible_at(now).then_some(window)
|
||||
}
|
||||
|
||||
events::send(tx_notify_ui, PeerEvent::CallToPlayEvents(merged.applied));
|
||||
fn call_windows(&self) -> HashMap<CallId, CallWindow> {
|
||||
let mut windows = call_windows_from_creator(self.local_peer_id, &self.local.events);
|
||||
for (author_id, slice) in &self.remote {
|
||||
windows.extend(call_windows_from_creator(
|
||||
*author_id,
|
||||
&slice.snapshot.events,
|
||||
));
|
||||
}
|
||||
windows
|
||||
}
|
||||
}
|
||||
|
||||
let peer_addresses = ctx.peer_game_db.read().await.get_peer_addresses();
|
||||
let peer_id = ctx.peer_id.clone();
|
||||
let handshake_ctx = HandshakeCtx::from_ctx(ctx, tx_notify_ui);
|
||||
ctx.task_tracker.spawn(async move {
|
||||
let deliveries = peer_addresses.into_iter().map(|peer_addr| {
|
||||
let event = event.clone();
|
||||
let peer_id = peer_id.clone();
|
||||
let handshake_ctx = handshake_ctx.clone();
|
||||
async move {
|
||||
deliver_to_peer(handshake_ctx, peer_addr, peer_id.as_ref(), event).await;
|
||||
}
|
||||
fn validate_author_snapshot(
|
||||
author_id: PeerId,
|
||||
snapshot: &CallToPlayAuthorSnapshot,
|
||||
) -> Result<(), CallToPlayValidationError> {
|
||||
snapshot
|
||||
.validate()
|
||||
.map_err(CallToPlayValidationError::Wire)?;
|
||||
let encoded_size = serde_json::to_vec(snapshot)
|
||||
.map_err(|_| CallToPlayValidationError::SnapshotEncoding)?
|
||||
.len();
|
||||
if encoded_size > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES {
|
||||
return Err(CallToPlayValidationError::SnapshotTooLarge {
|
||||
actual: encoded_size,
|
||||
maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
|
||||
});
|
||||
futures::future::join_all(deliveries).await;
|
||||
});
|
||||
}
|
||||
|
||||
let mut event_ids = HashSet::with_capacity(snapshot.events.len());
|
||||
let mut creator_calls = HashMap::<CallId, CreatorValidationState>::new();
|
||||
if snapshot
|
||||
.events
|
||||
.windows(2)
|
||||
.any(|events| events[0].at > events[1].at)
|
||||
{
|
||||
return Err(CallToPlayValidationError::NonMonotonicAuthorHistory);
|
||||
}
|
||||
for event in &snapshot.events {
|
||||
if !event_ids.insert(event.id) {
|
||||
return Err(CallToPlayValidationError::DuplicateEventId(event.id));
|
||||
}
|
||||
validate_event_fields(event)?;
|
||||
if is_creator_only_action(&event.action) && event.call_id.creator != author_id {
|
||||
return Err(CallToPlayValidationError::UnauthorizedAction {
|
||||
event_id: event.id,
|
||||
call_id: event.call_id,
|
||||
});
|
||||
}
|
||||
if let CallToPlayAction::Create { deadline, .. } = event.action
|
||||
&& creator_calls
|
||||
.insert(
|
||||
event.call_id,
|
||||
CreatorValidationState {
|
||||
created_at: event.at,
|
||||
last_at: event.at,
|
||||
deadline,
|
||||
terminal: false,
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err(CallToPlayValidationError::DuplicateCreate(event.call_id));
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen_roots = HashSet::new();
|
||||
for event in &snapshot.events {
|
||||
if event.call_id.creator != author_id {
|
||||
continue;
|
||||
}
|
||||
if matches!(event.action, CallToPlayAction::Create { .. }) {
|
||||
seen_roots.insert(event.call_id);
|
||||
continue;
|
||||
}
|
||||
if !seen_roots.contains(&event.call_id) {
|
||||
return Err(CallToPlayValidationError::MissingCreatorRoot(event.call_id));
|
||||
}
|
||||
let state = creator_calls
|
||||
.get_mut(&event.call_id)
|
||||
.ok_or(CallToPlayValidationError::MissingCreatorRoot(event.call_id))?;
|
||||
if event.at < state.created_at || event.at < state.last_at {
|
||||
return Err(CallToPlayValidationError::NonMonotonicCallHistory(
|
||||
event.call_id,
|
||||
));
|
||||
}
|
||||
if state.terminal {
|
||||
return Err(CallToPlayValidationError::ActionAfterTerminal(
|
||||
event.call_id,
|
||||
));
|
||||
}
|
||||
if let CallToPlayAction::AddTime { deadline } = event.action {
|
||||
if deadline <= state.deadline {
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "AddTime must extend the current deadline",
|
||||
});
|
||||
}
|
||||
state.deadline = deadline;
|
||||
}
|
||||
if matches!(
|
||||
event.action,
|
||||
CallToPlayAction::Cancel | CallToPlayAction::Start
|
||||
) {
|
||||
state.terminal = true;
|
||||
}
|
||||
state.last_at = event.at;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deliver_to_peer(
|
||||
handshake_ctx: HandshakeCtx,
|
||||
peer_addr: std::net::SocketAddr,
|
||||
peer_id: &str,
|
||||
event: CallToPlayEvent,
|
||||
) {
|
||||
let delivery = send_call_to_play_events(peer_addr, peer_id, vec![event]).await;
|
||||
match &delivery {
|
||||
Ok(CallToPlayAck::Rejected { reason }) => {
|
||||
log::warn!("Peer {peer_addr} rejected a Call to Play event: {reason}");
|
||||
}
|
||||
Ok(CallToPlayAck::Obsolete) => {
|
||||
log::debug!("Peer {peer_addr} already retired the Call to Play event");
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("Failed to deliver a Call to Play event to {peer_addr}: {err}");
|
||||
}
|
||||
Ok(
|
||||
CallToPlayAck::Applied
|
||||
| CallToPlayAck::Duplicate
|
||||
| CallToPlayAck::NeedHandshake
|
||||
| CallToPlayAck::NeedHistory,
|
||||
) => {}
|
||||
}
|
||||
|
||||
let Some(reason) = delivery_resync_reason(delivery.as_ref().map_err(|_| ())) else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = perform_handshake_with_peer(handshake_ctx, peer_addr, None).await {
|
||||
log::warn!("Failed to {reason} with {peer_addr}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn delivery_resync_reason(delivery: Result<&CallToPlayAck, ()>) -> Option<&'static str> {
|
||||
match delivery {
|
||||
Err(()) => Some("heal a failed Call to Play delivery"),
|
||||
Ok(CallToPlayAck::NeedHandshake) => Some("complete a requested Call to Play handshake"),
|
||||
Ok(CallToPlayAck::NeedHistory) => Some("restore missing Call to Play history"),
|
||||
Ok(
|
||||
CallToPlayAck::Applied
|
||||
| CallToPlayAck::Duplicate
|
||||
| CallToPlayAck::Obsolete
|
||||
| CallToPlayAck::Rejected { .. },
|
||||
) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_event(event: &CallToPlayEvent) -> Result<(), &'static str> {
|
||||
validate_nonempty(&event.id, MAX_ID_CHARS, "invalid event id")?;
|
||||
validate_nonempty(&event.call_id, MAX_ID_CHARS, "invalid call id")?;
|
||||
validate_nonempty(&event.actor_id, MAX_ID_CHARS, "invalid actor id")?;
|
||||
validate_nonempty(&event.actor_name, MAX_USERNAME_CHARS, "invalid actor name")?;
|
||||
fn validate_event_fields(event: &CallToPlayAuthorEvent) -> Result<(), CallToPlayValidationError> {
|
||||
event.validate().map_err(CallToPlayValidationError::Wire)?;
|
||||
if event.at <= 0 {
|
||||
return Err("invalid event timestamp");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "event timestamp must be positive",
|
||||
});
|
||||
}
|
||||
|
||||
match &event.action {
|
||||
CallToPlayAction::Create {
|
||||
game_id,
|
||||
max_players,
|
||||
scheduled_for,
|
||||
deadline,
|
||||
..
|
||||
} => {
|
||||
validate_nonempty(game_id, MAX_GAME_ID_CHARS, "invalid game id")?;
|
||||
if !(2..=64).contains(max_players) {
|
||||
return Err("max players must be between 2 and 64");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "max players must be between 2 and 64",
|
||||
});
|
||||
}
|
||||
if *deadline <= event.at {
|
||||
return Err("deadline must be after creation");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "deadline must be after creation",
|
||||
});
|
||||
}
|
||||
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
|
||||
return Err("scheduled call deadline must match its start time");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "scheduled call deadline must match its start time",
|
||||
});
|
||||
}
|
||||
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
|
||||
}
|
||||
CallToPlayAction::Respond { ready_at } => {
|
||||
if ready_at.is_some_and(|ready| ready < event.at) {
|
||||
return Err("ready time cannot be before the response");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "ready time cannot be before the response",
|
||||
});
|
||||
}
|
||||
}
|
||||
CallToPlayAction::SendMessage { message_id, text } => {
|
||||
validate_nonempty(message_id, MAX_ID_CHARS, "invalid message id")?;
|
||||
validate_nonempty(text, MAX_MESSAGE_CHARS, "invalid message")?;
|
||||
}
|
||||
CallToPlayAction::AddTime { deadline } => {
|
||||
if *deadline <= event.at {
|
||||
return Err("extended deadline must be in the future");
|
||||
return Err(CallToPlayValidationError::InvalidEvent {
|
||||
event_id: event.id,
|
||||
reason: "extended deadline must be after the action",
|
||||
});
|
||||
}
|
||||
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
|
||||
}
|
||||
CallToPlayAction::Cancel | CallToPlayAction::Start => {
|
||||
checked_retention_boundary(event.at, TERMINAL_RETENTION_MS, event.id)?;
|
||||
}
|
||||
CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => {
|
||||
}
|
||||
CallToPlayAction::Rsvp
|
||||
| CallToPlayAction::Leave
|
||||
| CallToPlayAction::Cancel
|
||||
| CallToPlayAction::Start => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nonempty(
|
||||
value: &str,
|
||||
max_chars: usize,
|
||||
error: &'static str,
|
||||
) -> Result<(), &'static str> {
|
||||
if value.trim().is_empty() || value.chars().count() > max_chars {
|
||||
return Err(error);
|
||||
fn checked_retention_boundary(
|
||||
timestamp: i64,
|
||||
retention: i64,
|
||||
event_id: EventNonce,
|
||||
) -> Result<i64, CallToPlayValidationError> {
|
||||
timestamp
|
||||
.checked_add(retention)
|
||||
.ok_or(CallToPlayValidationError::InvalidEvent {
|
||||
event_id,
|
||||
reason: "timestamp overflows its retention boundary",
|
||||
})
|
||||
}
|
||||
|
||||
const fn is_creator_only_action(action: &CallToPlayAction) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
CallToPlayAction::Create { .. }
|
||||
| CallToPlayAction::Cancel
|
||||
| CallToPlayAction::Start
|
||||
| CallToPlayAction::AddTime { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_local_terminal_reserve(
|
||||
snapshot: &CallToPlayAuthorSnapshot,
|
||||
terminal_action: bool,
|
||||
) -> Result<(), CallToPlayMutationError> {
|
||||
let event_limit = if terminal_action {
|
||||
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR
|
||||
} else {
|
||||
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - LOCAL_TERMINAL_EVENT_RESERVE
|
||||
};
|
||||
if snapshot.events.len() > event_limit {
|
||||
return Err(CallToPlayMutationError::EventHistoryFull);
|
||||
}
|
||||
|
||||
let byte_limit = if terminal_action {
|
||||
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES
|
||||
} else {
|
||||
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES - LOCAL_TERMINAL_BYTE_RESERVE
|
||||
};
|
||||
let encoded_size = serde_json::to_vec(snapshot)
|
||||
.map_err(|_| {
|
||||
CallToPlayMutationError::InvalidSnapshot(CallToPlayValidationError::SnapshotEncoding)
|
||||
})?
|
||||
.len();
|
||||
if encoded_size > byte_limit {
|
||||
return Err(CallToPlayMutationError::EventHistoryFull);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CreatorValidationState {
|
||||
created_at: i64,
|
||||
last_at: i64,
|
||||
deadline: i64,
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CallWindow {
|
||||
created_at: i64,
|
||||
deadline: i64,
|
||||
terminal_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl CallWindow {
|
||||
fn is_visible_at(self, now: i64) -> bool {
|
||||
let boundary = self.terminal_at.map_or_else(
|
||||
|| {
|
||||
self.deadline
|
||||
.checked_add(EXPIRED_RETENTION_MS)
|
||||
.expect("validated deadline retention cannot overflow")
|
||||
},
|
||||
|terminal_at| {
|
||||
terminal_at
|
||||
.checked_add(TERMINAL_RETENTION_MS)
|
||||
.expect("validated terminal retention cannot overflow")
|
||||
},
|
||||
);
|
||||
now <= boundary
|
||||
}
|
||||
}
|
||||
|
||||
fn call_windows_from_creator(
|
||||
creator: PeerId,
|
||||
events: &[CallToPlayAuthorEvent],
|
||||
) -> HashMap<CallId, CallWindow> {
|
||||
let mut windows = HashMap::new();
|
||||
for event in events {
|
||||
if event.call_id.creator != creator {
|
||||
continue;
|
||||
}
|
||||
match event.action {
|
||||
CallToPlayAction::Create { deadline, .. } => {
|
||||
windows.insert(
|
||||
event.call_id,
|
||||
CallWindow {
|
||||
created_at: event.at,
|
||||
deadline,
|
||||
terminal_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
CallToPlayAction::AddTime { deadline } => {
|
||||
if let Some(window) = windows.get_mut(&event.call_id) {
|
||||
window.deadline = deadline;
|
||||
}
|
||||
}
|
||||
CallToPlayAction::Cancel | CallToPlayAction::Start => {
|
||||
if let Some(window) = windows.get_mut(&event.call_id) {
|
||||
window.terminal_at = Some(event.at);
|
||||
}
|
||||
}
|
||||
CallToPlayAction::Respond { .. }
|
||||
| CallToPlayAction::Rsvp
|
||||
| CallToPlayAction::SendMessage { .. }
|
||||
| CallToPlayAction::Leave => {}
|
||||
}
|
||||
}
|
||||
windows
|
||||
}
|
||||
|
||||
fn call_window_from_creator_events(
|
||||
call_id: CallId,
|
||||
events: &[CallToPlayAuthorEvent],
|
||||
) -> Option<CallWindow> {
|
||||
call_windows_from_creator(call_id.creator, events).remove(&call_id)
|
||||
}
|
||||
|
||||
fn now_ms() -> Result<i64, CallToPlayMutationError> {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| CallToPlayMutationError::ClockUnavailable)?
|
||||
.as_millis();
|
||||
i64::try_from(millis).map_err(|_| CallToPlayMutationError::ClockUnavailable)
|
||||
}
|
||||
|
||||
fn random_nonce_bytes() -> Result<[u8; 16], CallToPlayMutationError> {
|
||||
let mut bytes = [0_u8; 16];
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.secure_random
|
||||
.fill(&mut bytes)
|
||||
.map_err(|_| CallToPlayMutationError::EntropyUnavailable)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lanspread_proto::{CallToPlayAck, CallToPlayAction, CallToPlayEvent};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::{
|
||||
CallToPlayStore,
|
||||
MAX_EVENTS,
|
||||
MergeError,
|
||||
TERMINAL_RETENTION_MS,
|
||||
delivery_resync_reason,
|
||||
use lanspread_proto::{
|
||||
CallToPlayAuthorSnapshot,
|
||||
ControlValidationError,
|
||||
LibrarySnapshot,
|
||||
MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS,
|
||||
PeerEndpoint,
|
||||
};
|
||||
|
||||
const TEST_NOW: i64 = 8_000_000_000_000;
|
||||
use super::*;
|
||||
use crate::peer_db::PeerGameDB;
|
||||
|
||||
fn create_event(id: &str) -> CallToPlayEvent {
|
||||
create_event_for("call-1", id)
|
||||
const NOW: i64 = 8_000_000_000_000;
|
||||
|
||||
fn peer(seed: u8) -> PeerId {
|
||||
PeerId::from_bytes([seed; 32])
|
||||
}
|
||||
|
||||
fn create_event_for(call_id: &str, id: &str) -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: id.to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
actor_id: "peer-alice".to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: TEST_NOW,
|
||||
fn session(seed: u8) -> RuntimeSessionId {
|
||||
RuntimeSessionId::from_bytes([seed; 16])
|
||||
}
|
||||
|
||||
fn nonce(value: u128) -> [u8; 16] {
|
||||
value.to_be_bytes()
|
||||
}
|
||||
|
||||
fn event_nonce(value: u128) -> EventNonce {
|
||||
EventNonce::from_bytes(nonce(value))
|
||||
}
|
||||
|
||||
fn call_nonce(value: u128) -> CallNonce {
|
||||
CallNonce::from_bytes(nonce(value))
|
||||
}
|
||||
|
||||
fn store(local_peer: PeerId) -> CallToPlayStore {
|
||||
CallToPlayStore::new(local_peer, session(1), "Local".to_owned())
|
||||
.expect("test store should be valid")
|
||||
}
|
||||
|
||||
fn snapshot(
|
||||
revision: u64,
|
||||
display_name: &str,
|
||||
events: Vec<CallToPlayAuthorEvent>,
|
||||
) -> CallToPlayAuthorSnapshot {
|
||||
CallToPlayAuthorSnapshot {
|
||||
revision,
|
||||
display_name: display_name.to_owned(),
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_event(
|
||||
creator: PeerId,
|
||||
call_id: CallId,
|
||||
id: u128,
|
||||
at: i64,
|
||||
deadline: i64,
|
||||
) -> CallToPlayAuthorEvent {
|
||||
assert_eq!(creator, call_id.creator);
|
||||
CallToPlayAuthorEvent {
|
||||
id: event_nonce(id),
|
||||
call_id,
|
||||
at,
|
||||
action: CallToPlayAction::Create {
|
||||
game_id: "game-1".to_string(),
|
||||
game_id: "game".to_owned(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: TEST_NOW + 60_000,
|
||||
deadline,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn action_event(id: &str, call_id: &str, action: CallToPlayAction) -> CallToPlayEvent {
|
||||
CallToPlayEvent {
|
||||
id: id.to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
actor_id: "peer-alice".to_string(),
|
||||
actor_name: "Alice".to_string(),
|
||||
at: TEST_NOW + 1_000,
|
||||
fn action_event(
|
||||
call_id: CallId,
|
||||
id: u128,
|
||||
at: i64,
|
||||
action: CallToPlayAction,
|
||||
) -> CallToPlayAuthorEvent {
|
||||
CallToPlayAuthorEvent {
|
||||
id: event_nonce(id),
|
||||
call_id,
|
||||
at,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_generations(count: usize) -> Vec<PeerEndpointGeneration> {
|
||||
let mut db = PeerGameDB::new();
|
||||
let endpoint = PeerEndpoint::new(peer(250), SocketAddr::from(([127, 0, 0, 1], 31_337)));
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let ticket = db
|
||||
.begin_candidate_negotiation(endpoint)
|
||||
.expect("candidate ticket");
|
||||
db.commit_authenticated_snapshot(
|
||||
endpoint,
|
||||
ticket,
|
||||
RuntimeSessionId::from_bytes(nonce(index as u128)),
|
||||
Some(LibrarySnapshot {
|
||||
revision: index as u64,
|
||||
games: Vec::new(),
|
||||
}),
|
||||
)
|
||||
.expect("commit should succeed")
|
||||
.expect("ticket should remain current")
|
||||
.endpoint_generation
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
author: PeerId,
|
||||
generation: PeerEndpointGeneration,
|
||||
runtime_session_id: RuntimeSessionId,
|
||||
snapshot: CallToPlayAuthorSnapshot,
|
||||
) -> PreparedRemoteAuthor {
|
||||
PreparedRemoteAuthor::prepare(author, generation, runtime_session_id, snapshot)
|
||||
}
|
||||
|
||||
fn create_intent(deadline: i64) -> CallToPlayLocalIntent {
|
||||
CallToPlayLocalIntent {
|
||||
call_id: None,
|
||||
action: CallToPlayLocalAction::Create {
|
||||
game_id: "game".to_owned(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn intent(call_id: CallId, action: CallToPlayLocalAction) -> CallToPlayLocalIntent {
|
||||
CallToPlayLocalIntent {
|
||||
call_id: Some(call_id),
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_events_without_reordering_new_history() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
let first = create_event("event-1");
|
||||
let second = create_event("event-2");
|
||||
let merged = store
|
||||
.merge_batch_at(vec![first.clone(), first.clone(), second.clone()], TEST_NOW)
|
||||
.expect("valid batch should merge");
|
||||
fn local_publish_generates_typed_ids_and_one_revision() {
|
||||
let local = peer(1);
|
||||
let mut store = store(local);
|
||||
let receipt = store
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Alice".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(7)),
|
||||
event_nonce(8),
|
||||
)
|
||||
.expect("valid Create should publish");
|
||||
|
||||
assert_eq!(merged.applied, [first, second]);
|
||||
assert_eq!(merged.duplicates, 1);
|
||||
assert_eq!(merged.obsolete, 0);
|
||||
assert!(!merged.needs_history());
|
||||
|
||||
let duplicate = store
|
||||
.merge_batch_at(vec![create_event("event-1")], TEST_NOW)
|
||||
.expect("stored duplicate should be harmless");
|
||||
assert!(duplicate.applied.is_empty());
|
||||
assert_eq!(duplicate.duplicates, 1);
|
||||
|
||||
let ids = event_ids(store.snapshot_at(TEST_NOW));
|
||||
assert_eq!(ids, ["event-1", "event-2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_leaves_store_unchanged() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
store
|
||||
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||
.expect("create should fit");
|
||||
let before = store.snapshot_at(TEST_NOW);
|
||||
|
||||
let mut invalid = action_event(
|
||||
"invalid",
|
||||
"call-1",
|
||||
CallToPlayAction::SendMessage {
|
||||
message_id: "message-1".to_string(),
|
||||
text: "valid before corruption".to_string(),
|
||||
},
|
||||
);
|
||||
invalid.action = CallToPlayAction::SendMessage {
|
||||
message_id: "message-1".to_string(),
|
||||
text: " ".to_string(),
|
||||
};
|
||||
assert_eq!(receipt.call_id, CallId::new(local, call_nonce(7)));
|
||||
assert_eq!(receipt.event_id, event_nonce(8));
|
||||
assert_eq!(receipt.revision, 1);
|
||||
let local_snapshot = store.local_snapshot_at(NOW).expect("snapshot");
|
||||
assert_eq!(local_snapshot.revision, 1);
|
||||
assert_eq!(local_snapshot.display_name, "Alice");
|
||||
assert_eq!(local_snapshot.events.len(), 1);
|
||||
assert_eq!(local_snapshot.events[0].at, NOW);
|
||||
|
||||
let remote_call = CallId::new(peer(2), call_nonce(9));
|
||||
let before = store.local_snapshot_at(NOW).expect("snapshot");
|
||||
assert_eq!(
|
||||
store.merge_batch_at(
|
||||
vec![
|
||||
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||
store.publish_local_at(
|
||||
intent(remote_call, CallToPlayLocalAction::Start),
|
||||
"Alice".to_owned(),
|
||||
NOW + 1,
|
||||
None,
|
||||
event_nonce(10),
|
||||
),
|
||||
Err(CallToPlayMutationError::CreatorAuthorityRequired(
|
||||
remote_call
|
||||
))
|
||||
);
|
||||
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
||||
|
||||
for invalid in [
|
||||
CallToPlayLocalIntent {
|
||||
call_id: Some(receipt.call_id),
|
||||
action: CallToPlayLocalAction::Create {
|
||||
game_id: "game".to_owned(),
|
||||
max_players: 4,
|
||||
scheduled_for: None,
|
||||
deadline: NOW + 60_000,
|
||||
},
|
||||
},
|
||||
CallToPlayLocalIntent {
|
||||
call_id: None,
|
||||
action: CallToPlayLocalAction::Rsvp,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
store.publish_local_at(
|
||||
invalid,
|
||||
],
|
||||
TEST_NOW,
|
||||
),
|
||||
Err(MergeError::Invalid("invalid message"))
|
||||
);
|
||||
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||
"Alice".to_owned(),
|
||||
NOW + 1,
|
||||
Some(call_nonce(11)),
|
||||
event_nonce(12),
|
||||
),
|
||||
Err(CallToPlayMutationError::InvalidIntent(_))
|
||||
));
|
||||
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_event_id_leaves_store_unchanged() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
store
|
||||
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||
.expect("create should fit");
|
||||
let before = store.snapshot_at(TEST_NOW);
|
||||
let mut conflicting = create_event("create");
|
||||
conflicting.actor_name = "Mallory".to_string();
|
||||
|
||||
fn display_name_only_change_is_bounded_and_published() {
|
||||
let mut store = store(peer(1));
|
||||
assert_eq!(store.local.revision, 0);
|
||||
assert_eq!(
|
||||
store.merge_batch_at(vec![conflicting], TEST_NOW),
|
||||
Err(MergeError::ConflictingEvent("create".to_string()))
|
||||
);
|
||||
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_extension_revive_expired_history_in_any_batch_order() {
|
||||
let extended_deadline = TEST_NOW + 20 * 60_000;
|
||||
let extension = action_event(
|
||||
"extend",
|
||||
"call-1",
|
||||
CallToPlayAction::AddTime {
|
||||
deadline: extended_deadline,
|
||||
},
|
||||
);
|
||||
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
|
||||
|
||||
for history in [
|
||||
vec![create_event("create"), extension.clone()],
|
||||
vec![extension.clone(), create_event("create")],
|
||||
] {
|
||||
let mut store = CallToPlayStore::default();
|
||||
store
|
||||
.merge_batch_at(vec![create_event("create")], TEST_NOW)
|
||||
.expect("initial create should fit");
|
||||
|
||||
let missing = store
|
||||
.merge_batch_at(vec![extension.clone()], after_recovery_window)
|
||||
.expect("missing history is an acknowledged merge outcome");
|
||||
assert!(missing.applied.is_empty());
|
||||
assert_eq!(missing.missing_call_ids, ["call-1"]);
|
||||
assert!(store.snapshot_at(after_recovery_window).is_empty());
|
||||
|
||||
let revived = store
|
||||
.merge_batch_at(history, after_recovery_window)
|
||||
.expect("complete history should revive the call atomically");
|
||||
assert_eq!(revived.applied.len(), 2);
|
||||
assert_eq!(
|
||||
event_ids(store.snapshot_at(after_recovery_window)),
|
||||
event_ids(revived.applied)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediately_obsolete_event_is_not_applied() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
let after_recovery_window = TEST_NOW + 6 * 60_000 + 1;
|
||||
let merged = store
|
||||
.merge_batch_at(vec![create_event("create")], after_recovery_window)
|
||||
.expect("obsolete is an acknowledged merge outcome");
|
||||
|
||||
assert!(merged.applied.is_empty());
|
||||
assert_eq!(merged.obsolete, 1);
|
||||
assert!(store.snapshot_at(after_recovery_window).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_tombstone_prevents_stale_history_resurrection() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
let start = action_event("start", "call-1", CallToPlayAction::Start);
|
||||
store
|
||||
.merge_batch_at(vec![create_event("create"), start.clone()], TEST_NOW)
|
||||
.expect("terminal history should merge");
|
||||
let after_terminal_retention = start.at + TERMINAL_RETENTION_MS + 1;
|
||||
.set_local_display_name_at("Alice".to_owned(), NOW)
|
||||
.expect("valid name"),
|
||||
Some(CallToPlayMutation { revision: 1 })
|
||||
);
|
||||
assert_eq!(
|
||||
store.snapshot_at(after_terminal_retention).as_slice(),
|
||||
std::slice::from_ref(&start)
|
||||
store
|
||||
.set_local_display_name_at("Alice".to_owned(), NOW)
|
||||
.expect("same name is a no-op"),
|
||||
None
|
||||
);
|
||||
|
||||
let stale = store
|
||||
.merge_batch_at(
|
||||
vec![
|
||||
create_event("create"),
|
||||
action_event(
|
||||
"extend",
|
||||
"call-1",
|
||||
CallToPlayAction::AddTime {
|
||||
deadline: TEST_NOW + 20 * 60_000,
|
||||
},
|
||||
),
|
||||
],
|
||||
after_terminal_retention,
|
||||
)
|
||||
.expect("stale history is an obsolete merge outcome");
|
||||
|
||||
assert!(stale.applied.is_empty());
|
||||
assert_eq!(stale.obsolete, 2);
|
||||
assert_eq!(store.snapshot_at(after_terminal_retention), [start]);
|
||||
let before = store.local_snapshot_at(NOW).expect("snapshot");
|
||||
assert!(
|
||||
store
|
||||
.set_local_display_name_at(
|
||||
"x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1),
|
||||
NOW,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_actions_succeed_at_the_active_history_cap() {
|
||||
for terminal_action in [CallToPlayAction::Start, CallToPlayAction::Cancel] {
|
||||
let mut store = full_active_store();
|
||||
let terminal = action_event("terminal", "call-1", terminal_action);
|
||||
fn terminal_reserve_keeps_one_settlement_slot() {
|
||||
for terminal in [CallToPlayLocalAction::Start, CallToPlayLocalAction::Cancel] {
|
||||
let local = peer(1);
|
||||
let call_id = CallId::new(local, call_nonce(1));
|
||||
let mut store = store(local);
|
||||
let mut events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - 1);
|
||||
events.push(create_event(local, call_id, 1, NOW, NOW + 60_000));
|
||||
events.extend((2..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128).map(|id| {
|
||||
action_event(
|
||||
call_id,
|
||||
id,
|
||||
NOW + i64::try_from(id).expect("event index fits in i64"),
|
||||
CallToPlayAction::Rsvp,
|
||||
)
|
||||
}));
|
||||
store.local.events = events;
|
||||
store.local.revision = 1;
|
||||
validate_author_snapshot(local, &store.local).expect("full reserved history is valid");
|
||||
|
||||
let merged = store
|
||||
.merge_batch_at(vec![terminal.clone()], TEST_NOW)
|
||||
.expect("terminal action should settle the full call");
|
||||
assert_eq!(merged.applied.as_slice(), std::slice::from_ref(&terminal));
|
||||
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 1);
|
||||
assert_eq!(
|
||||
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||
[terminal]
|
||||
store.publish_local_at(
|
||||
intent(call_id, CallToPlayLocalAction::Rsvp),
|
||||
"Local".to_owned(),
|
||||
NOW + 50_000,
|
||||
None,
|
||||
event_nonce(10_000),
|
||||
),
|
||||
Err(CallToPlayMutationError::EventHistoryFull)
|
||||
);
|
||||
let receipt = store
|
||||
.publish_local_at(
|
||||
intent(call_id, terminal),
|
||||
"Local".to_owned(),
|
||||
NOW + 50_000,
|
||||
None,
|
||||
event_nonce(10_001),
|
||||
)
|
||||
.expect("terminal action must use the reserved slot");
|
||||
assert_eq!(receipt.revision, 2);
|
||||
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
||||
assert_eq!(
|
||||
store
|
||||
.set_local_display_name_at("Renamed".to_owned(), NOW + 50_001)
|
||||
.expect("a non-event mutation does not consume another event slot"),
|
||||
Some(CallToPlayMutation { revision: 3 })
|
||||
);
|
||||
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_histories_do_not_consume_active_history_capacity() {
|
||||
let mut store = full_active_store();
|
||||
let mut terminal = action_event("call-1-start", "call-1", CallToPlayAction::Start);
|
||||
terminal.at = TEST_NOW + 2_000;
|
||||
store
|
||||
.merge_batch_at(vec![terminal], TEST_NOW)
|
||||
.expect("start should settle active history");
|
||||
fn pruning_keeps_exact_boundaries_then_removes_without_tombstones_and_bumps() {
|
||||
let local = peer(1);
|
||||
let mut unresolved = store(local);
|
||||
let deadline = NOW + 1_000;
|
||||
unresolved
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("create");
|
||||
assert_eq!(
|
||||
unresolved
|
||||
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS)
|
||||
.expect("exact boundary")
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let pruned = unresolved
|
||||
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("past boundary");
|
||||
assert!(pruned.events.is_empty());
|
||||
assert_eq!(pruned.revision, 2);
|
||||
|
||||
let created = create_event_for("call-2", "call-2-create");
|
||||
let merged = store
|
||||
.merge_batch_at(vec![created.clone()], TEST_NOW)
|
||||
.expect("terminal history must not block a new active call");
|
||||
assert_eq!(merged.applied, [created]);
|
||||
assert_eq!(store.snapshot_at(TEST_NOW).len(), MAX_EVENTS + 2);
|
||||
let mut terminal = store(local);
|
||||
let create = terminal
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(2)),
|
||||
event_nonce(2),
|
||||
)
|
||||
.expect("create");
|
||||
terminal
|
||||
.publish_local_at(
|
||||
intent(create.call_id, CallToPlayLocalAction::Start),
|
||||
"Local".to_owned(),
|
||||
NOW + 10,
|
||||
None,
|
||||
event_nonce(3),
|
||||
)
|
||||
.expect("start");
|
||||
assert_eq!(
|
||||
terminal
|
||||
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS)
|
||||
.expect("exact terminal boundary")
|
||||
.events
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
let pruned = terminal
|
||||
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS + 1)
|
||||
.expect("past terminal boundary");
|
||||
assert!(pruned.events.is_empty(), "no terminal tombstone remains");
|
||||
assert_eq!(pruned.revision, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_active_history_returns_an_error() {
|
||||
let mut store = full_active_store();
|
||||
let before = store.snapshot_at(TEST_NOW);
|
||||
fn responder_reads_project_only_when_local_pruning_changes_state() {
|
||||
let local = peer(1);
|
||||
let deadline = NOW + 1_000;
|
||||
let boundary = deadline + EXPIRED_RETENTION_MS;
|
||||
let mut store = store(local);
|
||||
store
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("create");
|
||||
|
||||
let (revision, publication) = store
|
||||
.responder_state_at(boundary)
|
||||
.expect("exact-boundary Pong state");
|
||||
assert_eq!(revision, 1);
|
||||
assert!(publication.is_none());
|
||||
assert_eq!(store.projection_count, 0);
|
||||
|
||||
let (snapshot, revision, publication) = store
|
||||
.local_responder_snapshot_at(boundary)
|
||||
.expect("exact-boundary Hello state");
|
||||
assert_eq!(snapshot.revision, revision);
|
||||
assert!(publication.is_none());
|
||||
assert_eq!(store.projection_count, 0);
|
||||
|
||||
let (revision, publication) = store
|
||||
.responder_state_at(boundary + 1)
|
||||
.expect("expired Pong state");
|
||||
let publication = publication.expect("a local prune requires one full publication");
|
||||
assert_eq!(revision, 2);
|
||||
assert_eq!(publication.local_revision, revision);
|
||||
assert!(publication.local_changed);
|
||||
assert!(publication.view.events.is_empty());
|
||||
assert_eq!(store.projection_count, 1);
|
||||
|
||||
let (snapshot, revision, publication) = store
|
||||
.local_responder_snapshot_at(boundary + 1)
|
||||
.expect("already-pruned Hello state");
|
||||
assert_eq!(snapshot.revision, revision);
|
||||
assert_eq!(revision, 2);
|
||||
assert!(publication.is_none());
|
||||
assert_eq!(store.projection_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responder_snapshot_excludes_remote_author_slices_from_full_local_view() {
|
||||
let local = peer(1);
|
||||
let participant = peer(2);
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let mut store = store(local);
|
||||
let create = store
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Alice".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("local Create should publish");
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Bob",
|
||||
vec![action_event(
|
||||
create.call_id,
|
||||
2,
|
||||
NOW + 1,
|
||||
CallToPlayAction::Rsvp,
|
||||
)],
|
||||
),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied { .. }
|
||||
));
|
||||
|
||||
let full_view = store.view_at(NOW + 2).expect("full local view");
|
||||
assert_eq!(full_view.events.len(), 2);
|
||||
assert_eq!(full_view.events[0].author_id, local);
|
||||
assert_eq!(full_view.events[1].author_id, participant);
|
||||
|
||||
let (responder_snapshot, revision, publication) = store
|
||||
.local_responder_snapshot_at(NOW + 2)
|
||||
.expect("local responder snapshot");
|
||||
assert_eq!(responder_snapshot.revision, revision);
|
||||
assert!(publication.is_none());
|
||||
assert_eq!(
|
||||
store.merge_batch_at(
|
||||
vec![action_event("overflow", "call-1", CallToPlayAction::Rsvp,)],
|
||||
TEST_NOW,
|
||||
responder_snapshot
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
[event_nonce(1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_remote_clear_preserves_local_author_and_projects_a_local_only_view() {
|
||||
let creator = peer(1);
|
||||
let participant = peer(2);
|
||||
let local = peer(3);
|
||||
let generations = endpoint_generations(2);
|
||||
let remote_call = CallId::new(creator, call_nonce(1));
|
||||
let mut store = store(local);
|
||||
store.observe_prepared_remote(prepare(
|
||||
creator,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![create_event(creator, remote_call, 1, NOW, NOW + 60_000)],
|
||||
),
|
||||
Err(MergeError::HistoryFull)
|
||||
));
|
||||
let local_call = store
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Local".to_owned(),
|
||||
NOW + 1,
|
||||
Some(call_nonce(2)),
|
||||
event_nonce(2),
|
||||
)
|
||||
.expect("local Create")
|
||||
.call_id;
|
||||
store
|
||||
.publish_local_at(
|
||||
intent(remote_call, CallToPlayLocalAction::Rsvp),
|
||||
"Local".to_owned(),
|
||||
NOW + 2,
|
||||
None,
|
||||
event_nonce(3),
|
||||
)
|
||||
.expect("local RSVP to the remote call");
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[1],
|
||||
session(3),
|
||||
snapshot(
|
||||
1,
|
||||
"Bob",
|
||||
vec![action_event(
|
||||
remote_call,
|
||||
4,
|
||||
NOW + 3,
|
||||
CallToPlayAction::Rsvp,
|
||||
)],
|
||||
),
|
||||
));
|
||||
assert_eq!(store.remote_author_count(), 2);
|
||||
assert_eq!(
|
||||
store.view_at(NOW + 4).expect("combined view").events.len(),
|
||||
4
|
||||
);
|
||||
|
||||
let local_before = store.local.clone();
|
||||
let projections_before = store.projection_count;
|
||||
let (publication, diagnostic) = store.clear_remote_authors_and_project_at(Ok(NOW + 4));
|
||||
|
||||
assert_eq!(diagnostic, None);
|
||||
assert_eq!(store.local, local_before);
|
||||
assert_eq!(publication.local_revision, local_before.revision);
|
||||
assert!(!publication.local_changed);
|
||||
assert_eq!(store.remote_author_count(), 0);
|
||||
assert!(store.remote_author_state(creator).is_none());
|
||||
assert!(store.remote_author_state(participant).is_none());
|
||||
assert_eq!(store.projection_count, projections_before + 1);
|
||||
assert_eq!(publication.view.events.len(), 1);
|
||||
assert_eq!(publication.view.events[0].call_id, local_call);
|
||||
assert_eq!(publication.view.events[0].author_id, local);
|
||||
assert_eq!(
|
||||
store
|
||||
.local
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
[event_nonce(2), event_nonce(3)],
|
||||
"the root-gated view must not erase the local author slice"
|
||||
);
|
||||
assert_eq!(store.snapshot_at(TEST_NOW), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_call_retains_complete_chat_history() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
let message = action_event(
|
||||
"message-event",
|
||||
"call-1",
|
||||
CallToPlayAction::SendMessage {
|
||||
message_id: "message-1".to_string(),
|
||||
text: "Ready when you are".to_string(),
|
||||
},
|
||||
fn bulk_remote_clear_falls_back_after_clock_or_prune_failure() {
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let remote = peer(1);
|
||||
let local = peer(2);
|
||||
let other_remote = peer(3);
|
||||
let remote_call = CallId::new(remote, call_nonce(1));
|
||||
|
||||
let mut clock_failure = store(local);
|
||||
let local_call = clock_failure
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(2)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("local Create")
|
||||
.call_id;
|
||||
clock_failure.observe_prepared_remote(prepare(
|
||||
remote,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Remote",
|
||||
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
|
||||
),
|
||||
));
|
||||
clock_failure.observe_prepared_remote(prepare(
|
||||
other_remote,
|
||||
generation,
|
||||
session(3),
|
||||
snapshot(0, "Other", Vec::new()),
|
||||
));
|
||||
assert_eq!(clock_failure.remote_author_count(), 2);
|
||||
let local_before = clock_failure.local.clone();
|
||||
let (publication, diagnostic) = clock_failure
|
||||
.clear_remote_authors_and_project_at(Err(CallToPlayMutationError::ClockUnavailable));
|
||||
assert_eq!(diagnostic, Some(CallToPlayMutationError::ClockUnavailable));
|
||||
assert_eq!(clock_failure.local, local_before);
|
||||
assert_eq!(clock_failure.remote_author_count(), 0);
|
||||
assert_eq!(publication.local_revision, local_before.revision);
|
||||
assert!(!publication.local_changed);
|
||||
assert_eq!(publication.view.events.len(), 1);
|
||||
assert_eq!(publication.view.events[0].call_id, local_call);
|
||||
|
||||
let deadline = NOW + 100;
|
||||
let mut prune_failure = store(local);
|
||||
prune_failure
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(3)),
|
||||
event_nonce(3),
|
||||
)
|
||||
.expect("expiring local Create");
|
||||
prune_failure.local.revision = u64::MAX;
|
||||
prune_failure.observe_prepared_remote(prepare(
|
||||
remote,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Remote",
|
||||
vec![create_event(remote, remote_call, 4, NOW, NOW + 60_000)],
|
||||
),
|
||||
));
|
||||
prune_failure.observe_prepared_remote(prepare(
|
||||
other_remote,
|
||||
generation,
|
||||
session(3),
|
||||
snapshot(0, "Other", Vec::new()),
|
||||
));
|
||||
assert_eq!(prune_failure.remote_author_count(), 2);
|
||||
let local_before = prune_failure.local.clone();
|
||||
let (publication, diagnostic) = prune_failure
|
||||
.clear_remote_authors_and_project_at(Ok(deadline + EXPIRED_RETENTION_MS + 1));
|
||||
assert_eq!(diagnostic, Some(CallToPlayMutationError::RevisionExhausted));
|
||||
assert_eq!(prune_failure.local, local_before);
|
||||
assert_eq!(prune_failure.remote_author_count(), 0);
|
||||
assert_eq!(publication.local_revision, u64::MAX);
|
||||
assert!(!publication.local_changed);
|
||||
assert!(publication.view.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_remote_clear_is_idempotent_after_one_normal_local_prune() {
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let remote = peer(1);
|
||||
let local = peer(2);
|
||||
let deadline = NOW + 100;
|
||||
let mut store = store(local);
|
||||
store
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("expiring local Create");
|
||||
let remote_call = CallId::new(remote, call_nonce(2));
|
||||
store.observe_prepared_remote(prepare(
|
||||
remote,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Remote",
|
||||
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
|
||||
),
|
||||
));
|
||||
let clear_at = deadline + EXPIRED_RETENTION_MS + 1;
|
||||
|
||||
let (first, first_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
|
||||
assert_eq!(first_diagnostic, None);
|
||||
assert!(first.local_changed);
|
||||
assert_eq!(first.local_revision, 2);
|
||||
assert!(first.view.events.is_empty());
|
||||
assert_eq!(store.remote_author_count(), 0);
|
||||
let local_after_first = store.local.clone();
|
||||
|
||||
let (second, second_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
|
||||
assert_eq!(second_diagnostic, None);
|
||||
assert!(!second.local_changed);
|
||||
assert_eq!(second.local_revision, 2);
|
||||
assert_eq!(second.view, first.view);
|
||||
assert_eq!(store.local, local_after_first);
|
||||
assert_eq!(store.remote_author_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_publication_freezes_one_boundary_and_fails_before_remote_commit() {
|
||||
let local = peer(1);
|
||||
let deadline = NOW + 1_000;
|
||||
let mut pruning_store = store(local);
|
||||
pruning_store
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("create");
|
||||
|
||||
let exact = pruning_store
|
||||
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
|
||||
.expect("exact boundary preparation");
|
||||
assert!(!exact.local_changed());
|
||||
let publication = pruning_store.view_from_prepared(exact);
|
||||
assert_eq!(publication.view.events.len(), 1);
|
||||
assert_eq!(publication.local_revision, 1);
|
||||
|
||||
let expired = pruning_store
|
||||
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("past-boundary preparation");
|
||||
assert!(expired.local_changed());
|
||||
assert_eq!(
|
||||
pruning_store.local.revision, 1,
|
||||
"preparation is transactional"
|
||||
);
|
||||
let merged = store
|
||||
.merge_batch_at(
|
||||
drop(expired);
|
||||
assert_eq!(
|
||||
pruning_store.local.revision, 1,
|
||||
"dropping a token is a no-op"
|
||||
);
|
||||
assert_eq!(pruning_store.local.events.len(), 1);
|
||||
let expired = pruning_store
|
||||
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("repeat past-boundary preparation");
|
||||
let publication = pruning_store.view_from_prepared(expired);
|
||||
assert!(publication.view.events.is_empty());
|
||||
assert_eq!(publication.local_revision, 2);
|
||||
assert!(publication.local_changed);
|
||||
|
||||
let mut exhausted = store(local);
|
||||
exhausted
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(2)),
|
||||
event_nonce(2),
|
||||
)
|
||||
.expect("create");
|
||||
exhausted.local.revision = u64::MAX;
|
||||
let before = exhausted.local.clone();
|
||||
assert!(matches!(
|
||||
exhausted.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1),
|
||||
Err(CallToPlayMutationError::RevisionExhausted)
|
||||
));
|
||||
assert_eq!(exhausted.local, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_prepared_projection_cannot_overwrite_a_newer_timer_view() {
|
||||
let author = peer(1);
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let call_id = CallId::new(author, call_nonce(1));
|
||||
let deadline = NOW + 100;
|
||||
let mut store = store(peer(2));
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![create_event(author, call_id, 1, NOW, deadline)],
|
||||
),
|
||||
));
|
||||
|
||||
let old = store
|
||||
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
|
||||
.expect("old projection");
|
||||
let newer = store
|
||||
.publication_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("newer projection");
|
||||
assert!(newer.view.events.is_empty());
|
||||
let replayed = store.view_from_prepared(old);
|
||||
assert!(replayed.view.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_time_recovers_during_the_five_minute_window() {
|
||||
let local = peer(1);
|
||||
let mut store = store(local);
|
||||
let deadline = NOW + 100;
|
||||
let created = store
|
||||
.publish_local_at(
|
||||
create_intent(deadline),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("create");
|
||||
let recovery_time = deadline + EXPIRED_RETENTION_MS;
|
||||
store
|
||||
.publish_local_at(
|
||||
intent(
|
||||
created.call_id,
|
||||
CallToPlayLocalAction::AddTime {
|
||||
deadline: recovery_time + 60_000,
|
||||
},
|
||||
),
|
||||
"Local".to_owned(),
|
||||
recovery_time,
|
||||
None,
|
||||
event_nonce(2),
|
||||
)
|
||||
.expect("AddTime at the exact recovery boundary should revive the call");
|
||||
assert_eq!(
|
||||
store
|
||||
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("snapshot")
|
||||
.events
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_slice_is_retained_hidden_and_creator_departure_hides_call() {
|
||||
let local = peer(3);
|
||||
let creator = peer(1);
|
||||
let participant = peer(2);
|
||||
let call_id = CallId::new(creator, call_nonce(1));
|
||||
let generations = endpoint_generations(3);
|
||||
let mut store = store(local);
|
||||
|
||||
let participant_event = action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp);
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(1, "Same name", vec![participant_event.clone()]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied { .. }
|
||||
));
|
||||
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
|
||||
assert!(store.remote_author_state(participant).is_some());
|
||||
|
||||
let root = create_event(creator, call_id, 1, NOW, NOW + 60_000);
|
||||
store.observe_prepared_remote(prepare(
|
||||
creator,
|
||||
generations[1],
|
||||
session(3),
|
||||
snapshot(1, "Same name", vec![root.clone()]),
|
||||
));
|
||||
let view = store.view_at(NOW + 2).expect("view");
|
||||
assert_eq!(view.events.len(), 2);
|
||||
assert_eq!(view.events[0].author_id, creator);
|
||||
assert_eq!(view.events[1].author_id, participant);
|
||||
assert_eq!(view.events[0].author_name, view.events[1].author_name);
|
||||
|
||||
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
|
||||
let view = store.view_at(NOW + 2).expect("participant departure view");
|
||||
assert_eq!(view.events.len(), 1);
|
||||
assert_eq!(view.events[0].author_id, creator);
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(1, "Same name", vec![participant_event]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied { .. }
|
||||
));
|
||||
|
||||
assert!(!store.remove_remote_author_if_generation(creator, generations[0]));
|
||||
assert!(store.remove_remote_author_if_generation(creator, generations[1]));
|
||||
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
|
||||
assert!(store.remote_author_state(participant).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "one state-transition matrix keeps its shared setup and assertions together"
|
||||
)]
|
||||
fn same_session_stale_and_invalid_preserve_and_rebind_but_new_invalid_clears() {
|
||||
let local = peer(9);
|
||||
let author = peer(1);
|
||||
let call_id = CallId::new(author, call_nonce(1));
|
||||
let generations = endpoint_generations(6);
|
||||
let mut store = store(local);
|
||||
let root = create_event(author, call_id, 1, NOW, NOW + 60_000);
|
||||
|
||||
assert_eq!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(5, "Alice", vec![root.clone()]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied {
|
||||
session_changed: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[1],
|
||||
session(2),
|
||||
snapshot(4, "Lower", vec![root.clone()]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::IgnoredStale {
|
||||
generation_rebound: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.remote_author_state(author)
|
||||
.expect("remote author should remain")
|
||||
.revision,
|
||||
5
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[1],
|
||||
session(2),
|
||||
snapshot(5, "Alice", vec![root.clone()]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Unchanged {
|
||||
generation_rebound: false
|
||||
}
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[2],
|
||||
session(2),
|
||||
snapshot(5, "Equal conflict", vec![root]),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::EqualRevisionConflict {
|
||||
generation_rebound: true
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
store.view_at(NOW).expect("view").events[0].author_name,
|
||||
"Alice"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[3],
|
||||
session(2),
|
||||
snapshot(6, " ", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::InvalidPreserved {
|
||||
generation_rebound: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.remote_author_state(author)
|
||||
.expect("preserved")
|
||||
.endpoint_generation,
|
||||
generations[3]
|
||||
);
|
||||
assert!(!store.remove_remote_author_if_generation(author, generations[2]));
|
||||
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[4],
|
||||
session(3),
|
||||
snapshot(0, " ", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::InvalidCleared(_)
|
||||
));
|
||||
assert!(store.remote_author_state(author).is_none());
|
||||
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[5],
|
||||
session(3),
|
||||
snapshot(0, "Restarted", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied {
|
||||
session_changed: true
|
||||
}
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.remote_author_state(author)
|
||||
.expect("restarted remote author should exist")
|
||||
.revision,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::too_many_lines,
|
||||
reason = "one author-isolation matrix keeps its shared fixtures and assertions together"
|
||||
)]
|
||||
fn invalid_duplicates_order_authority_and_bytes_are_author_isolated() {
|
||||
let generations = endpoint_generations(2);
|
||||
let author = peer(1);
|
||||
let other_creator = peer(2);
|
||||
let own_call = CallId::new(author, call_nonce(1));
|
||||
let other_call = CallId::new(other_creator, call_nonce(2));
|
||||
|
||||
let duplicate = action_event(other_call, 1, NOW, CallToPlayAction::Rsvp);
|
||||
let prepared = prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(1, "Alice", vec![duplicate.clone(), duplicate]),
|
||||
);
|
||||
assert!(matches!(
|
||||
prepared.validation_error(),
|
||||
Some(CallToPlayValidationError::DuplicateEventId(_))
|
||||
));
|
||||
|
||||
let prepared = prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![
|
||||
create_event("create"),
|
||||
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||
message.clone(),
|
||||
action_event(other_call, 1, NOW + 1, CallToPlayAction::Rsvp),
|
||||
action_event(other_call, 2, NOW, CallToPlayAction::Leave),
|
||||
],
|
||||
TEST_NOW,
|
||||
)
|
||||
.expect("visible history should merge");
|
||||
|
||||
assert_eq!(merged.applied.len(), 3);
|
||||
assert!(store.snapshot_at(TEST_NOW).contains(&message));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_history_preserves_roster_and_chat_for_display_window() {
|
||||
let mut store = CallToPlayStore::default();
|
||||
let message = action_event(
|
||||
"message",
|
||||
"call-1",
|
||||
CallToPlayAction::SendMessage {
|
||||
message_id: "message-1".to_string(),
|
||||
text: "Launching now".to_string(),
|
||||
},
|
||||
);
|
||||
let terminal = action_event("terminal", "call-1", CallToPlayAction::Start);
|
||||
let history = vec![
|
||||
create_event("create"),
|
||||
action_event("rsvp", "call-1", CallToPlayAction::Rsvp),
|
||||
message,
|
||||
terminal.clone(),
|
||||
];
|
||||
store
|
||||
.merge_batch_at(history.clone(), TEST_NOW)
|
||||
.expect("terminal history should merge");
|
||||
|
||||
let mut post_terminal = action_event(
|
||||
"post-terminal",
|
||||
"call-1",
|
||||
CallToPlayAction::SendMessage {
|
||||
message_id: "message-2".to_string(),
|
||||
text: "Too late".to_string(),
|
||||
},
|
||||
);
|
||||
post_terminal.at = terminal.at + 1;
|
||||
let obsolete = store
|
||||
.merge_batch_at(
|
||||
vec![create_event("different-create"), post_terminal],
|
||||
TEST_NOW,
|
||||
)
|
||||
.expect("terminal updates should be obsolete");
|
||||
assert!(obsolete.applied.is_empty());
|
||||
assert_eq!(obsolete.obsolete, 2);
|
||||
assert_eq!(
|
||||
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS),
|
||||
history
|
||||
),
|
||||
);
|
||||
assert_eq!(
|
||||
store.snapshot_at(terminal.at + TERMINAL_RETENTION_MS + 1),
|
||||
[terminal]
|
||||
prepared.validation_error(),
|
||||
Some(&CallToPlayValidationError::NonMonotonicAuthorHistory)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_failed_or_incomplete_deliveries_request_resync() {
|
||||
assert!(delivery_resync_reason(Err(())).is_some());
|
||||
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHandshake)).is_some());
|
||||
assert!(delivery_resync_reason(Ok(&CallToPlayAck::NeedHistory)).is_some());
|
||||
let prepared = prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![create_event(other_creator, other_call, 1, NOW, NOW + 1_000)],
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
prepared.validation_error(),
|
||||
Some(CallToPlayValidationError::UnauthorizedAction { .. })
|
||||
));
|
||||
|
||||
for ack in [
|
||||
CallToPlayAck::Applied,
|
||||
CallToPlayAck::Duplicate,
|
||||
CallToPlayAck::Obsolete,
|
||||
CallToPlayAck::Rejected {
|
||||
reason: "invalid".to_string(),
|
||||
},
|
||||
] {
|
||||
assert!(delivery_resync_reason(Ok(&ack)).is_none());
|
||||
let overflow = prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![create_event(author, own_call, 9, NOW, i64::MAX)],
|
||||
),
|
||||
);
|
||||
assert!(matches!(
|
||||
overflow.validation_error(),
|
||||
Some(CallToPlayValidationError::InvalidEvent {
|
||||
reason: "timestamp overflows its retention boundary",
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let mut oversized_events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
|
||||
let text = "😀".repeat(250);
|
||||
for id in 0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128 {
|
||||
oversized_events.push(action_event(
|
||||
other_call,
|
||||
id,
|
||||
NOW + i64::try_from(id).expect("event index fits in i64"),
|
||||
CallToPlayAction::SendMessage { text: text.clone() },
|
||||
));
|
||||
}
|
||||
let oversized = prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(1, "Alice", oversized_events),
|
||||
);
|
||||
assert!(matches!(
|
||||
oversized.validation_error(),
|
||||
Some(CallToPlayValidationError::Wire(
|
||||
ControlValidationError::EncodedTooLarge { .. }
|
||||
))
|
||||
));
|
||||
|
||||
let mut store = store(peer(9));
|
||||
let valid_root = create_event(author, own_call, 3, NOW, NOW + 60_000);
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(1, "Alice", vec![valid_root]),
|
||||
));
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(oversized),
|
||||
ObserveRemoteAuthorOutcome::InvalidPreserved { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.remote_author_state(author)
|
||||
.expect("valid remote author should remain")
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
fn full_active_store() -> CallToPlayStore {
|
||||
let mut history = Vec::with_capacity(MAX_EVENTS);
|
||||
history.push(create_event("create"));
|
||||
history.extend((1..MAX_EVENTS).map(|index| {
|
||||
action_event(&format!("event-{index}"), "call-1", CallToPlayAction::Rsvp)
|
||||
}));
|
||||
|
||||
let mut store = CallToPlayStore::default();
|
||||
store
|
||||
.merge_batch_at(history, TEST_NOW)
|
||||
.expect("active history should fit through the cap");
|
||||
store
|
||||
#[test]
|
||||
fn remote_expiry_hides_without_mutating_the_accepted_revision() {
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let author = peer(1);
|
||||
let call_id = CallId::new(author, call_nonce(1));
|
||||
let deadline = NOW + 100;
|
||||
let mut store = store(peer(2));
|
||||
store.observe_prepared_remote(prepare(
|
||||
author,
|
||||
generation,
|
||||
session(2),
|
||||
snapshot(
|
||||
7,
|
||||
"Alice",
|
||||
vec![create_event(author, call_id, 1, NOW, deadline)],
|
||||
),
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.view_at(deadline + EXPIRED_RETENTION_MS)
|
||||
.expect("exact boundary")
|
||||
.events
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.view_at(deadline + EXPIRED_RETENTION_MS + 1)
|
||||
.expect("expired view")
|
||||
.events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.remote_author_state(author)
|
||||
.expect("expired remote author should remain cached")
|
||||
.revision,
|
||||
7
|
||||
);
|
||||
}
|
||||
|
||||
fn event_ids(events: Vec<CallToPlayEvent>) -> Vec<String> {
|
||||
events.into_iter().map(|event| event.id).collect()
|
||||
#[test]
|
||||
fn remote_author_capacity_does_not_consume_local_capacity() {
|
||||
let generation = endpoint_generations(1)[0];
|
||||
let local = peer(200);
|
||||
let mut store = store(local);
|
||||
for seed in
|
||||
1..u8::try_from(MAX_CALL_TO_PLAY_AUTHORS).expect("author limit fits in one byte")
|
||||
{
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
peer(seed),
|
||||
generation,
|
||||
session(seed),
|
||||
snapshot(0, "Peer", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied { .. }
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
store.remote_author_count() + 1,
|
||||
MAX_CALL_TO_PLAY_AUTHORS,
|
||||
"the local author counts toward the total-author cap"
|
||||
);
|
||||
assert_eq!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
peer(100),
|
||||
generation,
|
||||
session(100),
|
||||
snapshot(0, "Extra", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::AtCapacity
|
||||
);
|
||||
|
||||
let receipt = store
|
||||
.publish_local_at(
|
||||
create_intent(NOW + 60_000),
|
||||
"Local".to_owned(),
|
||||
NOW,
|
||||
Some(call_nonce(1)),
|
||||
event_nonce(1),
|
||||
)
|
||||
.expect("remote capacity must not block local publication");
|
||||
assert_eq!(receipt.revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_view_is_deterministic_and_wholly_recomputed() {
|
||||
let local = peer(3);
|
||||
let creator = peer(1);
|
||||
let participant = peer(2);
|
||||
let call_id = CallId::new(creator, call_nonce(1));
|
||||
let generations = endpoint_generations(2);
|
||||
let mut store = store(local);
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
1,
|
||||
"Bob",
|
||||
vec![
|
||||
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
|
||||
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
|
||||
],
|
||||
),
|
||||
));
|
||||
assert!(store.remote_author_state(participant).is_none());
|
||||
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(
|
||||
2,
|
||||
"Bob",
|
||||
vec![
|
||||
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
|
||||
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
|
||||
],
|
||||
),
|
||||
));
|
||||
store.observe_prepared_remote(prepare(
|
||||
creator,
|
||||
generations[1],
|
||||
session(3),
|
||||
snapshot(
|
||||
1,
|
||||
"Alice",
|
||||
vec![create_event(creator, call_id, 1, NOW, NOW + 60_000)],
|
||||
),
|
||||
));
|
||||
let first = store.view_at(NOW + 3).expect("view");
|
||||
let second = store.view_at(NOW + 3).expect("view");
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(
|
||||
first
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.id)
|
||||
.collect::<Vec<_>>(),
|
||||
[event_nonce(1), event_nonce(2), event_nonce(3)]
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
store.observe_prepared_remote(prepare(
|
||||
participant,
|
||||
generations[0],
|
||||
session(2),
|
||||
snapshot(3, "Bob", Vec::new()),
|
||||
)),
|
||||
ObserveRemoteAuthorOutcome::Applied {
|
||||
session_changed: false
|
||||
}
|
||||
));
|
||||
let replaced = store.view_at(NOW + 3).expect("replacement view");
|
||||
assert_eq!(replaced.events.len(), 1);
|
||||
assert_eq!(replaced.events[0].author_id, creator);
|
||||
|
||||
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
|
||||
let replaced = store.view_at(NOW + 3).expect("view");
|
||||
assert_eq!(replaced.events.len(), 1);
|
||||
assert_eq!(replaced.events[0].author_id, creator);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user