Files
chanora/crates/chanora_state/src/channel_join.rs
T

1060 lines
38 KiB
Rust

//! Deterministic channel-join pending-state reducer.
//!
//! This module implements SDD-121 Phase A for the `chanora_state` crate:
//! Rust-owned authoritative current-channel membership, non-authoritative
//! pending join state, and reconnect/snapshot reconciliation.
use std::time::Instant;
/// Channel identifier at the reducer seam.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChannelId(pub u64);
/// Per-connection epoch used to reject stale outcomes and deltas.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ConnectionEpoch(pub u64);
/// Monotonic join/leave generation within reducer state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct JoinGeneration(pub u64);
/// Protocol request identifier unique within one connection epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JoinRequestId(pub u64);
/// Server-confirmed voice-channel membership.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthoritativeMembership {
/// Last server-authoritative current channel, or none if not in voice.
pub current_channel: Option<ChannelId>,
/// Epoch that produced the confirmed membership value.
pub confirmed_epoch: ConnectionEpoch,
}
/// Active non-authoritative join intent awaiting server confirmation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct JoinPending {
/// Requested target channel.
pub target_channel: ChannelId,
/// Previous confirmed channel to preserve on failure/timeout.
pub previous_confirmed_channel: Option<ChannelId>,
/// Protocol request id after command-send acceptance.
pub request_id: Option<JoinRequestId>,
/// Reducer generation for this intent.
pub generation: JoinGeneration,
/// Deterministic start time supplied by the caller.
pub started_at: Instant,
/// Connection epoch in which the intent was created.
pub connection_epoch: ConnectionEpoch,
}
/// Reducer-owned channel-join state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelJoinState {
/// Server-authoritative membership.
pub authoritative: AuthoritativeMembership,
/// Active pending join, if any.
pub pending: Option<JoinPending>,
/// Current snapshot/reconnect synchronization state.
pub sync_state: ChannelJoinSyncState,
/// Next generation to allocate.
pub next_generation: JoinGeneration,
/// Last sanitized join error for projection consumers.
pub last_join_error: Option<JoinErrorCode>,
}
impl ChannelJoinState {
/// Create channel-join reducer state for a connection epoch.
pub fn new(epoch: ConnectionEpoch) -> Self {
Self {
authoritative: AuthoritativeMembership {
current_channel: None,
confirmed_epoch: epoch,
},
pending: None,
sync_state: ChannelJoinSyncState::Ready,
next_generation: JoinGeneration(1),
last_join_error: None,
}
}
fn allocate_generation(&mut self) -> JoinGeneration {
let generation = self.next_generation;
self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1));
generation
}
fn bump_generation(&mut self) {
self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1));
}
fn current_epoch(&self) -> ConnectionEpoch {
match self.sync_state {
ChannelJoinSyncState::Ready => self.authoritative.confirmed_epoch,
ChannelJoinSyncState::Synchronizing { epoch, .. } => epoch,
}
}
}
/// State-sync readiness for channel actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is waiting for a snapshot boundary.
Synchronizing {
/// Reason for synchronization.
reason: SyncReason,
/// Epoch being synchronized.
epoch: ConnectionEpoch,
},
}
/// Reason channel-join state is synchronizing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncReason {
/// Initial snapshot is in progress.
InitialSnapshot,
/// Reconnect snapshot is in progress.
Reconnect,
}
/// Correlation key for protocol outcomes and timeout callbacks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JoinOutcomeKey {
/// Connection epoch of the pending join.
pub connection_epoch: ConnectionEpoch,
/// Generation of the pending join.
pub generation: JoinGeneration,
/// Protocol request id, once accepted for send.
pub request_id: Option<JoinRequestId>,
}
/// Reducer projection consumed by core/bridge mapping.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelJoinProjection {
/// Last confirmed current channel.
pub current_channel: Option<ChannelId>,
/// Non-authoritative pending target channel.
pub pending_target: Option<ChannelId>,
/// Start time of the active pending join.
pub pending_since: Option<Instant>,
/// Generation of the active pending join.
pub pending_generation: Option<JoinGeneration>,
/// Whether a new join can be initiated.
pub can_join: bool,
/// Whether leave is possible for current/pending state.
pub can_leave: bool,
/// Current sync state.
pub sync_state: ChannelJoinSyncState,
/// Last sanitized join error.
pub last_join_error: Option<JoinErrorCode>,
}
/// Source of authoritative membership input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthoritativeSource {
/// Live protocol delta.
LiveDelta,
/// Snapshot input.
Snapshot,
}
/// Events accepted by the channel-join reducer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelJoinEvent {
/// User requested a channel join.
UserJoinRequested {
/// Requested target channel.
target_channel: ChannelId,
/// Caller-supplied deterministic request time.
now: Instant,
},
/// Protocol command send accepted and produced a request id.
JoinCommandAccepted {
/// Pending generation accepted by the command path.
generation: JoinGeneration,
/// Protocol request id for future outcomes.
request_id: JoinRequestId,
},
/// Protocol command failed before a request id existed.
JoinCommandRejectedBeforeSend {
/// Pending generation rejected by the command path.
generation: JoinGeneration,
/// Sanitized failure kind.
error: JoinFailureKind,
},
/// Protocol command reported success; not authoritative membership.
ProtocolJoinSucceeded {
/// Outcome correlation key.
key: JoinOutcomeKey,
},
/// Protocol command reported failure.
ProtocolJoinFailed {
/// Outcome correlation key.
key: JoinOutcomeKey,
/// Sanitized failure kind.
failure: JoinFailureKind,
},
/// Join command timed out.
JoinTimeout {
/// Outcome correlation key.
key: JoinOutcomeKey,
/// Caller-supplied deterministic timeout time.
now: Instant,
},
/// Server-authoritative self membership changed.
AuthoritativeSelfMove {
/// Server-authoritative channel value.
channel: Option<ChannelId>,
/// Epoch of the live delta or snapshot source.
epoch: ConnectionEpoch,
/// Authoritative input source.
source: AuthoritativeSource,
},
/// User requested leave.
UserLeaveRequested {
/// Caller-supplied deterministic leave time.
now: Instant,
},
/// Reconnect started.
ReconnectStarted {
/// New connection epoch.
new_epoch: ConnectionEpoch,
/// Caller-supplied deterministic reconnect time.
now: Instant,
},
/// Fresh snapshot is ready.
SnapshotReady {
/// Snapshot current channel value.
current_channel: Option<ChannelId>,
/// Snapshot epoch.
epoch: ConnectionEpoch,
/// Caller-supplied deterministic snapshot time.
now: Instant,
},
}
/// Side-effect actions returned by the reducer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChannelJoinAction {
/// Send a protocol join command.
SendJoinCommand {
/// Requested target channel.
target_channel: ChannelId,
/// Generation associated with the command.
generation: JoinGeneration,
/// Connection epoch associated with the command.
epoch: ConnectionEpoch,
},
/// Start a deterministic join timeout.
StartJoinTimeout {
/// Outcome key the timeout will report.
key: JoinOutcomeKey,
/// Timeout start time.
started_at: Instant,
},
/// Cancel a deterministic join timeout.
CancelJoinTimeout {
/// Outcome key to cancel.
key: JoinOutcomeKey,
},
/// Send a protocol leave command.
SendLeaveCommand {
/// Current authoritative channel at leave request time.
current_channel: Option<ChannelId>,
/// Generation associated with the leave command.
generation: JoinGeneration,
/// Connection epoch associated with the leave command.
epoch: ConnectionEpoch,
},
/// Publish the latest projection.
PublishProjection(ChannelJoinProjection),
/// Emit a sanitized diagnostic.
EmitDiagnostic {
/// Sanitized diagnostic key.
key: JoinDiagnosticKey,
/// Optional stable error code.
code: Option<JoinErrorCode>,
},
}
/// Sanitized diagnostic event key.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinDiagnosticKey {
/// Join was requested.
JoinRequested,
/// Join command was accepted for send.
JoinCommandSent,
/// Duplicate target was coalesced.
JoinDuplicateCoalesced,
/// Join was confirmed authoritatively.
JoinConfirmed,
/// Join failed.
JoinFailed,
/// Join timed out.
JoinTimeout,
/// Stale outcome ignored.
JoinStaleOutcomeIgnored,
/// Pending join superseded by leave.
JoinPendingSupersededByLeave,
/// Reconnect synchronization started.
JoinReconnectSynchronizing,
/// Snapshot reconciled membership.
JoinSnapshotReconciled,
}
/// Status for a reducer transition.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinReduceStatus {
/// Event was accepted.
Accepted,
/// Duplicate same-target join was coalesced.
CoalescedSameTarget,
/// Intent was rejected.
Rejected(JoinIntentRejected),
/// Stale outcome was ignored.
StaleOutcomeIgnored,
/// Join was authoritatively confirmed.
Confirmed,
/// Join failed.
Failed(JoinFailureKind),
/// Join timed out.
TimedOut,
/// Join was superseded by leave.
SupersededByLeave,
/// State was reconciled by snapshot.
ReconciledBySnapshot,
}
/// User intent rejection reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinIntentRejected {
/// A different target is already pending.
JoinAlreadyPendingDifferentTarget,
/// Joins cannot start while synchronizing.
CannotJoinWhileSynchronizing,
}
/// Coarse failure kind without raw protocol payloads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinFailureKind {
/// Server denied the join.
Denied,
/// Network failure.
Network,
/// Protocol failure.
Protocol,
/// Timeout.
Timeout,
/// Unknown sanitized failure.
Unknown,
}
/// Stable sanitized error code consumed by bridge/UI mapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JoinErrorCode {
/// Duplicate same target was coalesced.
DuplicateSameTargetCoalesced,
/// Different target requested while pending.
JoinAlreadyPendingDifferentTarget,
/// Join was denied.
JoinDenied,
/// Protocol failure.
JoinProtocolFailure,
/// Network failure.
JoinNetworkFailure,
/// Join timed out.
JoinTimeout,
/// Join superseded by leave.
JoinSupersededByLeave,
/// Stale outcome ignored.
JoinStaleOutcomeIgnored,
/// Snapshot/live delta reconciled to a different channel.
JoinReconciledDifferentChannel,
/// Command rejected before send.
JoinCommandRejectedBeforeSend,
/// Cannot start while synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Complete reducer result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelJoinReduction {
/// Transition status.
pub status: JoinReduceStatus,
/// Projection after reduction.
pub projection: ChannelJoinProjection,
/// Side effects for the owning runtime.
pub actions: Vec<ChannelJoinAction>,
}
/// Reduce one channel-join event into state and side-effect actions.
pub fn reduce(state: &mut ChannelJoinState, event: ChannelJoinEvent) -> ChannelJoinReduction {
let mut actions = Vec::new();
let status = match event {
ChannelJoinEvent::UserJoinRequested {
target_channel,
now,
} => {
if matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. }) {
state.last_join_error = Some(JoinErrorCode::JoinCannotStartWhileSynchronizing);
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinFailed,
code: state.last_join_error,
});
JoinReduceStatus::Rejected(JoinIntentRejected::CannotJoinWhileSynchronizing)
} else if let Some(pending) = state.pending {
if pending.target_channel == target_channel {
state.last_join_error = Some(JoinErrorCode::DuplicateSameTargetCoalesced);
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinDuplicateCoalesced,
code: state.last_join_error,
});
JoinReduceStatus::CoalescedSameTarget
} else {
state.last_join_error = Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget);
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinFailed,
code: state.last_join_error,
});
JoinReduceStatus::Rejected(
JoinIntentRejected::JoinAlreadyPendingDifferentTarget,
)
}
} else {
let generation = state.allocate_generation();
let epoch = state.current_epoch();
state.pending = Some(JoinPending {
target_channel,
previous_confirmed_channel: state.authoritative.current_channel,
request_id: None,
generation,
started_at: now,
connection_epoch: epoch,
});
state.last_join_error = None;
let key = JoinOutcomeKey {
connection_epoch: epoch,
generation,
request_id: None,
};
actions.push(ChannelJoinAction::SendJoinCommand {
target_channel,
generation,
epoch,
});
actions.push(ChannelJoinAction::StartJoinTimeout {
key,
started_at: now,
});
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinRequested,
code: None,
});
JoinReduceStatus::Accepted
}
}
ChannelJoinEvent::JoinCommandAccepted {
generation,
request_id,
} => {
if let Some(pending) = state.pending.as_mut() {
if pending.generation == generation {
let old_key = pending_key(pending);
pending.request_id = Some(request_id);
let new_key = pending_key(pending);
actions.push(ChannelJoinAction::CancelJoinTimeout { key: old_key });
actions.push(ChannelJoinAction::StartJoinTimeout {
key: new_key,
started_at: pending.started_at,
});
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinCommandSent,
code: None,
});
JoinReduceStatus::Accepted
} else {
stale(state, &mut actions)
}
} else {
stale(state, &mut actions)
}
}
ChannelJoinEvent::JoinCommandRejectedBeforeSend { generation, error } => {
if let Some(pending) = state.pending {
if pending.generation == generation {
let key = pending_key(&pending);
state.authoritative.current_channel = pending.previous_confirmed_channel;
state.pending = None;
state.last_join_error = Some(JoinErrorCode::JoinCommandRejectedBeforeSend);
actions.push(ChannelJoinAction::CancelJoinTimeout { key });
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinFailed,
code: state.last_join_error,
});
JoinReduceStatus::Failed(error)
} else {
stale(state, &mut actions)
}
} else {
stale(state, &mut actions)
}
}
ChannelJoinEvent::ProtocolJoinSucceeded { key } => {
if key_matches(state.pending, key) {
JoinReduceStatus::Accepted
} else {
stale(state, &mut actions)
}
}
ChannelJoinEvent::ProtocolJoinFailed { key, failure } => {
if key_matches(state.pending, key) {
let pending = state.pending.expect("key match requires pending");
state.authoritative.current_channel = pending.previous_confirmed_channel;
state.pending = None;
state.last_join_error = Some(failure_error_code(failure));
actions.push(ChannelJoinAction::CancelJoinTimeout { key });
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinFailed,
code: state.last_join_error,
});
JoinReduceStatus::Failed(failure)
} else {
stale(state, &mut actions)
}
}
ChannelJoinEvent::JoinTimeout { key, now: _ } => {
if key_matches(state.pending, key) {
let pending = state.pending.expect("key match requires pending");
state.authoritative.current_channel = pending.previous_confirmed_channel;
state.pending = None;
state.last_join_error = Some(JoinErrorCode::JoinTimeout);
actions.push(ChannelJoinAction::CancelJoinTimeout { key });
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinTimeout,
code: state.last_join_error,
});
JoinReduceStatus::TimedOut
} else {
stale(state, &mut actions)
}
}
ChannelJoinEvent::AuthoritativeSelfMove {
channel,
epoch,
source: _,
} => {
if epoch != state.current_epoch() {
stale(state, &mut actions)
} else {
state.authoritative.current_channel = channel;
state.authoritative.confirmed_epoch = epoch;
if let Some(pending) = state.pending {
let key = pending_key(&pending);
state.pending = None;
actions.push(ChannelJoinAction::CancelJoinTimeout { key });
if channel == Some(pending.target_channel) {
state.last_join_error = None;
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinConfirmed,
code: None,
});
JoinReduceStatus::Confirmed
} else {
state.last_join_error = Some(JoinErrorCode::JoinReconciledDifferentChannel);
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinSnapshotReconciled,
code: state.last_join_error,
});
JoinReduceStatus::ReconciledBySnapshot
}
} else {
JoinReduceStatus::Accepted
}
}
}
ChannelJoinEvent::UserLeaveRequested { now: _ } => {
let generation = state.allocate_generation();
let epoch = state.current_epoch();
if let Some(pending) = state.pending {
let key = pending_key(&pending);
state.pending = None;
state.last_join_error = Some(JoinErrorCode::JoinSupersededByLeave);
actions.push(ChannelJoinAction::CancelJoinTimeout { key });
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinPendingSupersededByLeave,
code: state.last_join_error,
});
if state.authoritative.current_channel.is_some() {
actions.push(ChannelJoinAction::SendLeaveCommand {
current_channel: state.authoritative.current_channel,
generation,
epoch,
});
}
JoinReduceStatus::SupersededByLeave
} else {
if state.authoritative.current_channel.is_some() {
actions.push(ChannelJoinAction::SendLeaveCommand {
current_channel: state.authoritative.current_channel,
generation,
epoch,
});
}
JoinReduceStatus::Accepted
}
}
ChannelJoinEvent::ReconnectStarted { new_epoch, now: _ } => {
if let Some(pending) = state.pending {
actions.push(ChannelJoinAction::CancelJoinTimeout {
key: pending_key(&pending),
});
}
state.pending = None;
state.sync_state = ChannelJoinSyncState::Synchronizing {
reason: SyncReason::Reconnect,
epoch: new_epoch,
};
state.authoritative.confirmed_epoch = new_epoch;
state.bump_generation();
state.last_join_error = None;
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinReconnectSynchronizing,
code: None,
});
JoinReduceStatus::Accepted
}
ChannelJoinEvent::SnapshotReady {
current_channel,
epoch,
now: _,
} => {
if epoch != state.current_epoch() {
stale(state, &mut actions)
} else {
let pending = state.pending;
if let Some(pending) = pending {
actions.push(ChannelJoinAction::CancelJoinTimeout {
key: pending_key(&pending),
});
}
state.authoritative.current_channel = current_channel;
state.authoritative.confirmed_epoch = epoch;
state.pending = None;
state.sync_state = ChannelJoinSyncState::Ready;
state.bump_generation();
state.last_join_error = match pending {
Some(pending) if current_channel != Some(pending.target_channel) => {
Some(JoinErrorCode::JoinReconciledDifferentChannel)
}
_ => None,
};
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinSnapshotReconciled,
code: state.last_join_error,
});
if pending.is_some() && state.last_join_error.is_none() {
JoinReduceStatus::Confirmed
} else {
JoinReduceStatus::ReconciledBySnapshot
}
}
}
};
finish(status, state, actions)
}
/// Build the channel-join projection for the current reducer state.
pub fn project(state: &ChannelJoinState) -> ChannelJoinProjection {
let pending = state.pending;
let synchronizing = matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. });
ChannelJoinProjection {
current_channel: state.authoritative.current_channel,
pending_target: pending.map(|pending| pending.target_channel),
pending_since: pending.map(|pending| pending.started_at),
pending_generation: pending.map(|pending| pending.generation),
can_join: !synchronizing && pending.is_none(),
can_leave: !synchronizing
&& (state.authoritative.current_channel.is_some() || pending.is_some()),
sync_state: state.sync_state,
last_join_error: state.last_join_error,
}
}
fn finish(
status: JoinReduceStatus,
state: &ChannelJoinState,
mut actions: Vec<ChannelJoinAction>,
) -> ChannelJoinReduction {
let projection = project(state);
actions.push(ChannelJoinAction::PublishProjection(projection.clone()));
ChannelJoinReduction {
status,
projection,
actions,
}
}
fn pending_key(pending: &JoinPending) -> JoinOutcomeKey {
JoinOutcomeKey {
connection_epoch: pending.connection_epoch,
generation: pending.generation,
request_id: pending.request_id,
}
}
fn key_matches(pending: Option<JoinPending>, key: JoinOutcomeKey) -> bool {
pending.is_some_and(|pending| pending_key(&pending) == key)
}
fn stale(state: &mut ChannelJoinState, actions: &mut Vec<ChannelJoinAction>) -> JoinReduceStatus {
state.last_join_error = Some(JoinErrorCode::JoinStaleOutcomeIgnored);
actions.push(ChannelJoinAction::EmitDiagnostic {
key: JoinDiagnosticKey::JoinStaleOutcomeIgnored,
code: state.last_join_error,
});
JoinReduceStatus::StaleOutcomeIgnored
}
fn failure_error_code(failure: JoinFailureKind) -> JoinErrorCode {
match failure {
JoinFailureKind::Denied => JoinErrorCode::JoinDenied,
JoinFailureKind::Network => JoinErrorCode::JoinNetworkFailure,
JoinFailureKind::Protocol => JoinErrorCode::JoinProtocolFailure,
JoinFailureKind::Timeout => JoinErrorCode::JoinTimeout,
JoinFailureKind::Unknown => JoinErrorCode::JoinProtocolFailure,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn epoch(value: u64) -> ConnectionEpoch {
ConnectionEpoch(value)
}
fn channel(value: u64) -> ChannelId {
ChannelId(value)
}
fn request(value: u64) -> JoinRequestId {
JoinRequestId(value)
}
fn now(offset: u64) -> Instant {
Instant::now() + std::time::Duration::from_millis(offset)
}
fn accepted_pending(state: &mut ChannelJoinState, target: ChannelId) -> JoinOutcomeKey {
let start = now(1);
let reduction = reduce(
state,
ChannelJoinEvent::UserJoinRequested {
target_channel: target,
now: start,
},
);
let generation = reduction
.projection
.pending_generation
.expect("pending generation");
let request_id = request(7);
reduce(
state,
ChannelJoinEvent::JoinCommandAccepted {
generation,
request_id,
},
);
JoinOutcomeKey {
connection_epoch: epoch(1),
generation,
request_id: Some(request_id),
}
}
#[test]
fn user_join_creates_pending_and_send_start_timeout_actions() {
let mut state = ChannelJoinState::new(epoch(1));
let start = now(1);
let reduction = reduce(
&mut state,
ChannelJoinEvent::UserJoinRequested {
target_channel: channel(10),
now: start,
},
);
assert_eq!(reduction.status, JoinReduceStatus::Accepted);
assert_eq!(reduction.projection.pending_target, Some(channel(10)));
assert!(matches!(
reduction.actions[0],
ChannelJoinAction::SendJoinCommand {
target_channel: ChannelId(10),
generation: JoinGeneration(1),
epoch: ConnectionEpoch(1)
}
));
assert!(
matches!(reduction.actions[1], ChannelJoinAction::StartJoinTimeout { key: JoinOutcomeKey { connection_epoch: ConnectionEpoch(1), generation: JoinGeneration(1), request_id: None }, started_at } if started_at == start)
);
}
#[test]
fn duplicate_same_target_coalesces() {
let mut state = ChannelJoinState::new(epoch(1));
reduce(
&mut state,
ChannelJoinEvent::UserJoinRequested {
target_channel: channel(10),
now: now(1),
},
);
let reduction = reduce(
&mut state,
ChannelJoinEvent::UserJoinRequested {
target_channel: channel(10),
now: now(2),
},
);
assert_eq!(reduction.status, JoinReduceStatus::CoalescedSameTarget);
assert_eq!(
reduction.projection.pending_generation,
Some(JoinGeneration(1))
);
assert!(!reduction.actions.iter().any(|action| matches!(
action,
ChannelJoinAction::SendJoinCommand { .. } | ChannelJoinAction::StartJoinTimeout { .. }
)));
}
#[test]
fn different_target_while_pending_rejects_and_serializes() {
let mut state = ChannelJoinState::new(epoch(1));
reduce(
&mut state,
ChannelJoinEvent::UserJoinRequested {
target_channel: channel(10),
now: now(1),
},
);
let reduction = reduce(
&mut state,
ChannelJoinEvent::UserJoinRequested {
target_channel: channel(11),
now: now(2),
},
);
assert_eq!(
reduction.status,
JoinReduceStatus::Rejected(JoinIntentRejected::JoinAlreadyPendingDifferentTarget)
);
assert_eq!(reduction.projection.pending_target, Some(channel(10)));
assert_eq!(
reduction.projection.last_join_error,
Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget)
);
assert!(!reduction
.actions
.iter()
.any(|action| matches!(action, ChannelJoinAction::SendJoinCommand { .. })));
}
#[test]
fn authoritative_current_channel_not_changed_until_self_move_or_snapshot() {
let mut state = ChannelJoinState::new(epoch(1));
state.authoritative.current_channel = Some(channel(1));
let key = accepted_pending(&mut state, channel(2));
let success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key });
assert_eq!(success.projection.current_channel, Some(channel(1)));
assert_eq!(success.projection.pending_target, Some(channel(2)));
let confirmed = reduce(
&mut state,
ChannelJoinEvent::AuthoritativeSelfMove {
channel: Some(channel(2)),
epoch: epoch(1),
source: AuthoritativeSource::LiveDelta,
},
);
assert_eq!(confirmed.status, JoinReduceStatus::Confirmed);
assert_eq!(confirmed.projection.current_channel, Some(channel(2)));
assert_eq!(confirmed.projection.pending_target, None);
}
#[test]
fn failure_and_timeout_clear_pending_and_preserve_previous_authoritative_channel() {
let mut failed = ChannelJoinState::new(epoch(1));
failed.authoritative.current_channel = Some(channel(1));
let fail_key = accepted_pending(&mut failed, channel(2));
let fail_reduction = reduce(
&mut failed,
ChannelJoinEvent::ProtocolJoinFailed {
key: fail_key,
failure: JoinFailureKind::Denied,
},
);
assert_eq!(
fail_reduction.status,
JoinReduceStatus::Failed(JoinFailureKind::Denied)
);
assert_eq!(fail_reduction.projection.current_channel, Some(channel(1)));
assert_eq!(fail_reduction.projection.pending_target, None);
let mut timed_out = ChannelJoinState::new(epoch(1));
timed_out.authoritative.current_channel = Some(channel(1));
let timeout_key = accepted_pending(&mut timed_out, channel(2));
let timeout_reduction = reduce(
&mut timed_out,
ChannelJoinEvent::JoinTimeout {
key: timeout_key,
now: now(9),
},
);
assert_eq!(timeout_reduction.status, JoinReduceStatus::TimedOut);
assert_eq!(
timeout_reduction.projection.current_channel,
Some(channel(1))
);
assert_eq!(timeout_reduction.projection.pending_target, None);
}
#[test]
fn stale_outcomes_ignored_by_epoch_generation_or_request_mismatch() {
let mut state = ChannelJoinState::new(epoch(1));
state.authoritative.current_channel = Some(channel(1));
let key = accepted_pending(&mut state, channel(2));
for stale_key in [
JoinOutcomeKey {
connection_epoch: epoch(2),
..key
},
JoinOutcomeKey {
generation: JoinGeneration(99),
..key
},
JoinOutcomeKey {
request_id: Some(request(99)),
..key
},
] {
let reduction = reduce(
&mut state,
ChannelJoinEvent::ProtocolJoinFailed {
key: stale_key,
failure: JoinFailureKind::Network,
},
);
assert_eq!(reduction.status, JoinReduceStatus::StaleOutcomeIgnored);
assert_eq!(reduction.projection.current_channel, Some(channel(1)));
assert_eq!(reduction.projection.pending_target, Some(channel(2)));
}
}
#[test]
fn leave_supersedes_pending_and_emits_leave_action_if_current_exists() {
let mut state = ChannelJoinState::new(epoch(1));
state.authoritative.current_channel = Some(channel(1));
let key = accepted_pending(&mut state, channel(2));
let reduction = reduce(
&mut state,
ChannelJoinEvent::UserLeaveRequested { now: now(3) },
);
assert_eq!(reduction.status, JoinReduceStatus::SupersededByLeave);
assert_eq!(reduction.projection.current_channel, Some(channel(1)));
assert_eq!(reduction.projection.pending_target, None);
assert!(reduction.actions.iter().any(|action| matches!(
action,
ChannelJoinAction::SendLeaveCommand {
current_channel: Some(ChannelId(1)),
generation: JoinGeneration(2),
epoch: ConnectionEpoch(1)
}
)));
let stale_success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key });
assert_eq!(stale_success.status, JoinReduceStatus::StaleOutcomeIgnored);
assert_eq!(stale_success.projection.current_channel, Some(channel(1)));
}
#[test]
fn reconnect_clears_stales_pending_and_updates_sync_epoch() {
let mut state = ChannelJoinState::new(epoch(1));
state.authoritative.current_channel = Some(channel(1));
let key = accepted_pending(&mut state, channel(2));
let reconnect = reduce(
&mut state,
ChannelJoinEvent::ReconnectStarted {
new_epoch: epoch(2),
now: now(4),
},
);
assert_eq!(reconnect.projection.pending_target, None);
assert_eq!(
reconnect.projection.sync_state,
ChannelJoinSyncState::Synchronizing {
reason: SyncReason::Reconnect,
epoch: epoch(2)
}
);
assert!(!reconnect.projection.can_join);
let stale_failure = reduce(
&mut state,
ChannelJoinEvent::ProtocolJoinFailed {
key,
failure: JoinFailureKind::Network,
},
);
assert_eq!(stale_failure.status, JoinReduceStatus::StaleOutcomeIgnored);
}
#[test]
fn snapshot_replaces_authoritative_membership_and_clears_or_reconciles_pending() {
let mut confirmed = ChannelJoinState::new(epoch(1));
accepted_pending(&mut confirmed, channel(2));
let confirm = reduce(
&mut confirmed,
ChannelJoinEvent::SnapshotReady {
current_channel: Some(channel(2)),
epoch: epoch(1),
now: now(5),
},
);
assert_eq!(confirm.status, JoinReduceStatus::Confirmed);
assert_eq!(confirm.projection.current_channel, Some(channel(2)));
assert_eq!(confirm.projection.pending_target, None);
let mut reconciled = ChannelJoinState::new(epoch(1));
accepted_pending(&mut reconciled, channel(2));
let reconcile = reduce(
&mut reconciled,
ChannelJoinEvent::SnapshotReady {
current_channel: Some(channel(3)),
epoch: epoch(1),
now: now(6),
},
);
assert_eq!(reconcile.status, JoinReduceStatus::ReconciledBySnapshot);
assert_eq!(reconcile.projection.current_channel, Some(channel(3)));
assert_eq!(reconcile.projection.pending_target, None);
assert_eq!(
reconcile.projection.last_join_error,
Some(JoinErrorCode::JoinReconciledDifferentChannel)
);
}
}