blob: 093615aa94331b251f962e6fc763c33ba24d293e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Packet simulation layer for deterministic network testing.
//!
//! This module provides a packet-level network simulator that models:
//! - Per-path latency with exponential delay distribution
//! - Packet loss and replay (duplication) at delivery time
//! - Automatic network partitioning with configurable lifecycle
//! - Automatic path clogging with exponential duration
//! - Link capacity limits with random eviction
//! - Per-command link filtering via `LinkFilter` (`EnumSet<Command2>`)
//!
//! Partitions are implemented via per-link `LinkFilter`s. When a link's
//! filter is empty, all packets are silently dropped. When specific commands
//! are removed from the filter, only those command types are blocked.
//! External code can manipulate individual link filters via
//! [`PacketSimulator::link_filter`].
//!
//! # Delivery model
//!
//! This simulator uses **pull-based batch delivery**, [`PacketSimulator::step()`] returns
//! a `Vec<Packet>` containing all packets ready in the current tick. The caller
//! processes the batch after `step()` returns. This means packets delivered in
//! the same tick cannot trigger chain reactions within that tick.
use crate::ready_queue::{Ready, ReadyQueue};
use enumset::EnumSet;
use iggy_binary_protocol::{Command2, GenericHeader};
use rand::RngExt;
use rand_xoshiro::Xoshiro256Plus;
use rand_xoshiro::rand_core::SeedableRng;
use server_common::Message;
use std::collections::HashMap;
/// Per-link command filter. An `EnumSet<Command2>` where:
/// - [`ALLOW_ALL`] = all commands pass (link fully enabled)
/// - [`BLOCK_ALL`] = all commands blocked (link fully disabled/partitioned)
/// - Custom sets = only matching commands pass through
pub type LinkFilter = EnumSet<Command2>;
/// Link filter that allows all commands through (link fully enabled).
pub const ALLOW_ALL: LinkFilter = EnumSet::all();
/// Link filter that blocks all commands (link fully disabled / partitioned).
pub const BLOCK_ALL: LinkFilter = EnumSet::empty();
/// Identifies a process (replica or client) in the simulation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ProcessId {
Replica(u8),
Client(u128),
}
/// A packet in flight through the simulated network.
#[derive(Debug)]
pub struct Packet {
pub from: ProcessId,
pub to: ProcessId,
pub message: Message<GenericHeader>,
/// Tick at which this packet becomes deliverable.
pub ready_at: u64,
}
impl Clone for Packet {
fn clone(&self) -> Self {
Self {
from: self.from,
to: self.to,
message: self.message.deep_copy(),
ready_at: self.ready_at,
}
}
}
impl Ready for Packet {
fn ready_at(&self) -> u64 {
self.ready_at
}
}
/// Configuration for the packet simulator.
#[derive(Debug, Clone)]
pub struct PacketSimulatorOptions {
/// Minimum one-way delay in ticks.
pub one_way_delay_min: u64,
/// Mean one-way delay in ticks (exponential distribution).
pub one_way_delay_mean: u64,
/// Probability of dropping a packet at delivery time [0.0, 1.0].
pub packet_loss_probability: f64,
/// Probability of replaying/duplicating a packet at delivery time [0.0, 1.0].
pub replay_probability: f64,
/// Maximum number of packets in a single link's queue.
pub link_capacity: u8,
/// Probability per tick that a partition occurs (when not partitioned).
pub partition_probability: f64,
/// Probability per tick that a partition resolves (when partitioned).
pub unpartition_probability: f64,
/// Minimum ticks a partition lasts.
pub partition_stability: u32,
/// Minimum ticks of full connectivity before next partition.
pub unpartition_stability: u32,
/// How partitions are generated.
pub partition_mode: PartitionMode,
/// Whether partitions are symmetric or asymmetric.
pub partition_symmetry: PartitionSymmetry,
/// Probability per tick that any given path gets clogged.
pub path_clog_probability: f64,
/// Mean duration (ticks) of a clog (exponential distribution).
pub path_clog_duration_mean: u64,
/// Number of replica (node) processes.
pub node_count: u8,
/// Maximum number of client processes.
pub client_count: u8,
/// PRNG seed for deterministic behavior.
pub seed: u64,
}
impl Default for PacketSimulatorOptions {
fn default() -> Self {
Self {
one_way_delay_min: 1,
one_way_delay_mean: 3,
packet_loss_probability: 0.0,
replay_probability: 0.0,
link_capacity: 64,
partition_probability: 0.0,
unpartition_probability: 0.0,
partition_stability: 0,
unpartition_stability: 0,
partition_mode: PartitionMode::None,
partition_symmetry: PartitionSymmetry::Symmetric,
path_clog_probability: 0.0,
path_clog_duration_mean: 0,
node_count: 1,
client_count: 0,
seed: 0,
}
}
}
/// Per-path link: holds packets in a [`ReadyQueue`] sorted by `ready_at`.
struct Link {
/// Packets waiting to be delivered, ordered by `ready_at` (min-heap).
packets: ReadyQueue<Packet>,
/// Tick until which this link is clogged. Clogged when `clogged_till` > `current_tick`.
clogged_till: u64,
/// Per-command filter controlling which commands pass through this link.
/// [`ALLOW_ALL`] = fully enabled (default), [`BLOCK_ALL`] = fully disabled.
filter: LinkFilter,
/// Optional predicate to drop specific packets. Checked after the link filter
/// but before the random loss check.
drop_packet_fn: Option<fn(&Packet) -> bool>,
}
#[allow(clippy::missing_fields_in_debug)]
impl std::fmt::Debug for Link {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Link")
.field("packets", &self.packets)
.field("clogged_till", &self.clogged_till)
.field("filter", &self.filter)
.field("drop_packet_fn", &self.drop_packet_fn.map(|_| "<fn>"))
.finish()
}
}
impl Link {
fn new(capacity: u8) -> Self {
Self {
packets: ReadyQueue::with_capacity(capacity as usize),
clogged_till: 0,
filter: ALLOW_ALL,
drop_packet_fn: None,
}
}
}
/// Determines how automatic partitions are created.
/// Only nodes (replicas) are partitioned. There will always be exactly two partitions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PartitionMode {
/// Disable automatic partitioning.
#[default]
None,
/// Draws the size of the partition uniformly at random from [1, n-1].
/// Replicas are randomly assigned a partition.
UniformSize,
/// Assigns each node to a partition uniformly at random.
/// Biases towards equal-size partitions.
UniformPartition,
/// Isolates exactly one random node.
IsolateSingle,
}
/// Whether partitions are symmetric or asymmetric.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PartitionSymmetry {
#[default]
Symmetric,
Asymmetric,
}
/// The packet simulator manages a matrix of links between processes.
pub struct PacketSimulator {
options: PacketSimulatorOptions,
/// Flat array of links. Index = `from_idx` * `max_processes` + `to_idx`.
links: Vec<Link>,
/// Maximum number of processes (determines link array size).
max_processes: usize,
/// Mapping from [`ProcessId`] to flat index.
process_indices: HashMap<ProcessId, usize>,
/// Next flat index to assign to a newly registered client.
/// Initialized to `replica_count` and incremented on each new registration.
next_index: usize,
/// Current tick (network global time).
current_tick: u64,
/// PRNG for deterministic randomness.
prng: Xoshiro256Plus,
/// Whether an automatic partition is currently active.
auto_partition_active: bool,
/// Per-node partition assignment (true = partition A, false = partition B).
auto_partition: Vec<bool>,
/// Countdown timer for partition/unpartition stability.
auto_partition_stability: u32,
/// Scratch buffer for Fisher-Yates shuffle in [`UniformSize`] partition mode.
auto_partition_nodes: Vec<usize>,
/// Reusable buffer for delivered packets.
delivered: Vec<Packet>,
}
impl PacketSimulator {
/// Create a new packet simulator.
///
/// # Panics
/// Panics if `node_count` is 0, or if delay/probability parameters are invalid.
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn new(options: PacketSimulatorOptions) -> Self {
let node_count = options.node_count as usize;
let client_count = options.client_count as usize;
assert!(node_count > 0, "node_count must be > 0, got {node_count}");
assert!(
options.one_way_delay_min >= 1,
"one_way_delay_min must be >= 1 (got {}), zero causes unbounded replay loops",
options.one_way_delay_min
);
assert!(
options.one_way_delay_mean >= options.one_way_delay_min,
"one_way_delay_mean ({}) must be >= one_way_delay_min ({})",
options.one_way_delay_mean,
options.one_way_delay_min
);
assert!(
options.packet_loss_probability >= 0.0 && options.packet_loss_probability <= 1.0,
"packet_loss_probability must be in [0.0, 1.0], got {}",
options.packet_loss_probability
);
assert!(
options.replay_probability >= 0.0 && options.replay_probability <= 1.0,
"replay_probability must be in [0.0, 1.0], got {}",
options.replay_probability
);
assert!(
options.partition_probability >= 0.0 && options.partition_probability <= 1.0,
"partition_probability must be in [0.0, 1.0], got {}",
options.partition_probability
);
assert!(
options.unpartition_probability >= 0.0 && options.unpartition_probability <= 1.0,
"unpartition_probability must be in [0.0, 1.0], got {}",
options.unpartition_probability
);
assert!(
options.path_clog_probability >= 0.0 && options.path_clog_probability <= 1.0,
"path_clog_probability must be in [0.0, 1.0], got {}",
options.path_clog_probability
);
let max_processes = node_count + client_count;
// Pre-register all replicas
let mut process_indices = HashMap::new();
for i in 0..node_count {
process_indices.insert(ProcessId::Replica(i as u8), i);
}
// Create link matrix (NxN)
let link_count = max_processes * max_processes;
let link_capacity = options.link_capacity;
let links = (0..link_count).map(|_| Link::new(link_capacity)).collect();
// Start with unpartition_stability grace period
let initial_stability = options.unpartition_stability;
let seed = options.seed;
Self {
options,
links,
max_processes,
process_indices,
next_index: node_count,
current_tick: 0,
prng: Xoshiro256Plus::seed_from_u64(seed),
auto_partition_active: false,
auto_partition: vec![false; node_count],
auto_partition_stability: initial_stability,
auto_partition_nodes: (0..node_count).collect(),
delivered: Vec::new(),
}
}
/// Register a client process. Returns its flat index.
/// Re-registering a client with the same ID returns the same index.
///
/// # Panics
/// Panics if the maximum number of processes has been reached.
pub fn register_client(&mut self, client_id: u128) -> usize {
if let Some(&idx) = self.process_indices.get(&ProcessId::Client(client_id)) {
return idx;
}
let idx = self.next_index;
assert!(
idx < self.max_processes,
"Too many processes registered (max: {})",
self.max_processes
);
self.process_indices
.insert(ProcessId::Client(client_id), idx);
self.next_index += 1;
idx
}
/// Resolve a `ProcessId` to its flat index.
/// Fast path for replicas (direct arithmetic), `HashMap` fallback for clients.
fn process_index(&self, id: ProcessId) -> Option<usize> {
match id {
ProcessId::Replica(i) => {
let idx = i as usize;
if idx < self.auto_partition.len() {
Some(idx)
} else {
None
}
}
ProcessId::Client(_) => self.process_indices.get(&id).copied(),
}
}
/// Get the flat link index for a (from, to) pair.
/// Panics on unknown processes.
fn link_index(&self, from: ProcessId, to: ProcessId) -> usize {
let from_idx = self
.process_index(from)
.unwrap_or_else(|| panic!("unknown process: {from:?}"));
let to_idx = self
.process_index(to)
.unwrap_or_else(|| panic!("unknown process: {to:?}"));
from_idx * self.max_processes + to_idx
}
/// Submit a packet into the network.
///
/// Always enqueues the packet. Loss and replay checks happen at delivery
/// time in `step()`. If the link is at capacity, a random existing packet
/// is evicted to make room.
pub fn submit(&mut self, from: ProcessId, to: ProcessId, message: Message<GenericHeader>) {
let delay = Self::calculate_delay(&mut self.prng, &self.options);
let ready_at = self.current_tick.saturating_add(delay);
let packet = Packet {
from,
to,
message,
ready_at,
};
let idx = self.link_index(from, to);
// If at capacity, evict a random existing packet
if self.links[idx].packets.len() >= self.options.link_capacity as usize {
self.links[idx].packets.remove_random(&mut self.prng);
tracing::trace!(?from, ?to, "evicted random packet (link at capacity)");
}
self.links[idx].packets.push(packet);
}
/// Calculate a random delay using exponential distribution.
/// Returns max(min, exponential(mean)).
fn calculate_delay(prng: &mut Xoshiro256Plus, options: &PacketSimulatorOptions) -> u64 {
let min = options.one_way_delay_min;
let mean = options.one_way_delay_mean;
let exp = Self::random_exponential(prng, mean);
min.max(exp)
}
/// Generate an exponentially distributed random value with the given mean.
/// Uses inverse CDF: -mean * ln(U) where U ~ Uniform(0,1).
#[allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
fn random_exponential(prng: &mut Xoshiro256Plus, mean: u64) -> u64 {
let u: f64 = prng.random::<f64>();
if u > 0.0 {
(-(mean as f64) * u.ln()) as u64
} else {
// Fallback for u == 0.0 (ln(0) is -inf).
mean.saturating_mul(20)
}
}
/// Number of registered processes.
const fn process_count(&self) -> usize {
self.next_index
}
/// Returns a mutable reference to the link's filter.
/// This is the per-link command filter — `EnumSet<Command2>`.
/// Set to [`BLOCK_ALL`] to block all packets (partition).
/// Set to [`ALLOW_ALL`] to allow all packets (default).
/// Remove specific commands to selectively filter.
pub fn link_filter(&mut self, from: ProcessId, to: ProcessId) -> &mut LinkFilter {
let idx = self.link_index(from, to);
&mut self.links[idx].filter
}
/// Check whether a link is currently enabled (filter is not empty).
#[must_use]
pub fn is_link_enabled(&self, from: ProcessId, to: ProcessId) -> bool {
let idx = self.link_index(from, to);
!self.links[idx].filter.is_empty()
}
/// Clear all pending packets on a specific link.
pub fn link_clear(&mut self, from: ProcessId, to: ProcessId) {
let idx = self.link_index(from, to);
self.links[idx].packets.clear();
}
/// Returns a mutable reference to the link's optional drop-packet predicate.
///
/// When set, the predicate is called for each packet after the link filter check
/// but before the random loss check. If it returns `true`, the packet is dropped.
pub fn link_drop_packet_fn(
&mut self,
from: ProcessId,
to: ProcessId,
) -> &mut Option<fn(&Packet) -> bool> {
let idx = self.link_index(from, to);
&mut self.links[idx].drop_packet_fn
}
/// Disable a process by blocking all links to and from it.
///
/// Packets already queued on those links remain but will be dropped at
/// delivery time because the link filter is [`BLOCK_ALL`].
pub fn process_disable(&mut self, process: ProcessId) {
let all_processes: Vec<ProcessId> = self.process_indices.keys().copied().collect();
for other in all_processes {
if other == process {
continue;
}
*self.link_filter(process, other) = BLOCK_ALL;
*self.link_filter(other, process) = BLOCK_ALL;
}
}
/// Re-enable a process by allowing all links to and from it.
pub fn process_enable(&mut self, process: ProcessId) {
let all_processes: Vec<ProcessId> = self.process_indices.keys().copied().collect();
for other in all_processes {
if other == process {
continue;
}
*self.link_filter(process, other) = ALLOW_ALL;
*self.link_filter(other, process) = ALLOW_ALL;
}
}
// TODO: implement record/replay_recorded for deterministic replay support.
/// Deliver all packets that are ready at the current tick.
/// Returns a `Vec` of packets that should be delivered. Does NOT advance the tick.
///
/// This returns the full batch at once. Packets delivered in the same tick cannot trigger
/// chain reactions within that tick - the caller processes them after this returns.
///
/// The returned `Vec` is taken from an internal buffer via `std::mem::take`.
/// After processing, pass it back via [`recycle_buffer`](Self::recycle_buffer).
///
/// At delivery time, the following happens:
/// 1. If link is clogged -> skip (don't dequeue, clogging delays delivery)
/// 2. Remove a random ready packet via reservoir sampling
/// 3. If packet's command is not in the link filter -> drop
/// 4. If `drop_packet_fn` returns true -> drop
/// 5. Random packet loss -> drop
/// 6. Random replay -> clone and re-enqueue with new delay
/// 7. If survived -> include in delivered vec
pub fn step(&mut self) -> Vec<Packet> {
self.delivered.clear();
let Self {
links,
prng,
options,
current_tick,
delivered,
max_processes,
next_index,
..
} = self;
let process_count = *next_index;
for from in 0..process_count {
for to in 0..process_count {
let idx = from * *max_processes + to;
let link = &mut links[idx];
// Clogged links don't deliver — packets stay in queue
if link.clogged_till > *current_tick {
continue;
}
loop {
let Some(packet) = link.packets.remove_ready(prng, *current_tick) else {
break;
};
// Per-command link filter check: drop if command not in filter
let command = packet.message.header().command;
if !link.filter.contains(command) {
tracing::trace!(?command, "packet dropped (command filtered)");
continue;
}
// Custom drop predicate check
if let Some(should_drop) = link.drop_packet_fn
&& should_drop(&packet)
{
tracing::trace!("packet dropped (drop_packet_fn)");
continue;
}
// Random loss check
if prng.random::<f64>() < options.packet_loss_probability {
tracing::trace!("packet dropped (loss probability)");
continue;
}
// Random replay check: clone and re-enqueue with eviction at capacity
if prng.random::<f64>() < options.replay_probability {
let delay = Self::calculate_delay(prng, options);
let replay = Packet {
ready_at: (*current_tick).saturating_add(delay),
..packet.clone()
};
if link.packets.len() >= options.link_capacity as usize {
link.packets.remove_random(prng);
tracing::trace!("evicted random packet for replay (link at capacity)");
}
link.packets.push(replay);
tracing::trace!("packet replayed");
}
delivered.push(packet);
}
}
}
std::mem::take(&mut self.delivered)
}
/// Return a previously taken buffer for reuse.
pub fn recycle_buffer(&mut self, mut buf: Vec<Packet>) {
buf.clear();
self.delivered = buf;
}
/// Advance the network's tick counter.
/// Handles automatic partition lifecycle and random path clogging.
pub fn tick(&mut self) {
self.current_tick += 1;
// Partition lifecycle
if self.auto_partition_stability > 0 {
self.auto_partition_stability -= 1;
} else if self.auto_partition_active {
if self.prng.random::<f64>() < self.options.unpartition_probability {
self.auto_partition_active = false;
self.auto_partition_stability = self.options.unpartition_stability;
self.auto_partition.iter_mut().for_each(|p| *p = false);
// This resets ALL link filters to ALLOW_ALL, cancelling any
// manually-set per-command filters. Use partition_mode: None if you
// need custom filters to persist across partition cycles.
for link in &mut self.links {
link.filter = ALLOW_ALL;
}
tracing::debug!(partition = ?self.auto_partition, "unpartitioned network");
}
} else if self.options.node_count > 1
&& self.prng.random::<f64>() < self.options.partition_probability
{
self.auto_partition_network();
tracing::debug!(partition = ?self.auto_partition, "partitioned network");
}
// Random path clogging
let process_count = self.process_count();
for from in 0..process_count {
for to in 0..process_count {
if self.prng.random::<f64>() < self.options.path_clog_probability {
let duration = Self::random_exponential(
&mut self.prng,
self.options.path_clog_duration_mean,
);
let idx = from * self.max_processes + to;
self.links[idx].clogged_till = self.current_tick.saturating_add(duration);
tracing::debug!(from, to, duration, "path clogged");
}
}
}
}
/// Partition the network into two groups. Guaranteed to isolate at least one replica.
fn auto_partition_network(&mut self) {
let node_count = self.options.node_count as usize;
assert!(node_count > 1);
match self.options.partition_mode {
PartitionMode::None => {
self.auto_partition.iter_mut().for_each(|p| *p = false);
}
PartitionMode::UniformSize => {
let partition_size = self.prng.random_range(1..node_count);
// Reset and shuffle pre-allocated node indices using Fisher-Yates
for (i, node) in self.auto_partition_nodes.iter_mut().enumerate() {
*node = i;
}
for i in (1..node_count).rev() {
let j = self.prng.random_range(0..=i);
self.auto_partition_nodes.swap(i, j);
}
for (i, &node) in self.auto_partition_nodes.iter().enumerate() {
self.auto_partition[node] = i < partition_size;
}
}
PartitionMode::UniformPartition => {
let mut all_same = true;
self.auto_partition[0] = self.prng.random::<bool>();
for i in 1..node_count {
self.auto_partition[i] = self.prng.random::<bool>();
if self.auto_partition[i] != self.auto_partition[i - 1] {
all_same = false;
}
}
if all_same {
// Force at least one node into the opposite partition.
let n = self.prng.random_range(0..node_count);
self.auto_partition[n] = !self.auto_partition[0];
}
}
PartitionMode::IsolateSingle => {
self.auto_partition.iter_mut().for_each(|p| *p = false);
let n = self.prng.random_range(0..node_count);
self.auto_partition[n] = true;
}
}
self.auto_partition_active = true;
self.auto_partition_stability = self.options.partition_stability;
// Apply partition to links
let asymmetric_side = self.prng.random::<bool>();
let process_count = self.process_count();
for from in 0..process_count {
for to in 0..process_count {
let from_is_node = from < self.options.node_count as usize;
let to_is_node = to < self.options.node_count as usize;
let enabled = !from_is_node
|| !to_is_node
|| self.auto_partition[from] == self.auto_partition[to]
|| (self.options.partition_symmetry == PartitionSymmetry::Asymmetric
&& self.auto_partition[from] == asymmetric_side);
let idx = from * self.max_processes + to;
self.links[idx].filter = if enabled { ALLOW_ALL } else { BLOCK_ALL };
}
}
}
/// Get the current tick.
#[must_use]
pub const fn current_tick(&self) -> u64 {
self.current_tick
}
/// Clear all partitions, restoring full connectivity.
///
/// Resets auto partition state and sets **all** link filters to `ALLOW_ALL`,
/// including any manually-set per-command filters. If you need custom filters
/// to survive partition clearing, set `partition_mode: None` and manage
/// partitions manually.
pub fn clear_partition(&mut self) {
self.auto_partition_active = false;
self.auto_partition_stability = self.options.unpartition_stability;
self.auto_partition.iter_mut().for_each(|p| *p = false);
for link in &mut self.links {
link.filter = ALLOW_ALL;
}
tracing::debug!("partitions cleared");
}
/// Clog a specific link (both directions) indefinitely until `unclog()` is called.
pub fn clog(&mut self, from: ProcessId, to: ProcessId) {
let idx = self.link_index(from, to);
self.links[idx].clogged_till = u64::MAX;
let idx = self.link_index(to, from);
self.links[idx].clogged_till = u64::MAX;
tracing::debug!(?from, ?to, "link clogged");
}
/// Unclog a specific link (both directions).
pub fn unclog(&mut self, from: ProcessId, to: ProcessId) {
let idx = self.link_index(from, to);
self.links[idx].clogged_till = 0;
let idx = self.link_index(to, from);
self.links[idx].clogged_till = 0;
tracing::debug!(?from, ?to, "link unclogged");
}
/// Get the number of packets in flight across all links.
#[must_use]
pub fn packets_in_flight(&self) -> usize {
self.links.iter().map(|l| l.packets.len()).sum()
}
}
#[allow(clippy::missing_fields_in_debug)]
impl std::fmt::Debug for PacketSimulator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PacketSimulator")
.field("current_tick", &self.current_tick)
.field("max_processes", &self.max_processes)
.field("processes_registered", &self.process_indices.len())
.field("packets_in_flight", &self.packets_in_flight())
.field("auto_partition_active", &self.auto_partition_active)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::cast_possible_truncation)]
fn create_test_message() -> Message<GenericHeader> {
let size = std::mem::size_of::<GenericHeader>();
let mut message = Message::<GenericHeader>::new(size);
// `Message::new` zeroes the buffer, leaving the header `size` field 0;
// set it to the frame length so the message survives `try_from` (e.g.
// on `deep_copy`), which now floors `size >= size_of::<H>()`.
let header: &mut GenericHeader =
bytemuck::checked::try_from_bytes_mut(&mut message.as_mut_slice()[..size])
.expect("zeroed bytes are valid");
header.size = size as u32;
message
}
#[allow(clippy::cast_possible_truncation)]
fn create_test_message_with_command(command: Command2) -> Message<GenericHeader> {
let size = std::mem::size_of::<GenericHeader>();
let mut buf = vec![0u8; size];
let header: &mut GenericHeader =
bytemuck::checked::try_from_bytes_mut(&mut buf).expect("zeroed bytes are valid");
header.command = command;
header.size = size as u32;
Message::try_from(server_common::iobuf::Owned::<4096>::copy_from_slice(&buf))
.expect("generic test buffer must contain a valid generic message")
}
/// Helper: disable all links to/from a given replica (isolate it).
fn isolate_replica(sim: &mut PacketSimulator, replica: u8, replica_count: u8) {
for i in 0..replica_count {
if i != replica {
*sim.link_filter(ProcessId::Replica(i), ProcessId::Replica(replica)) = BLOCK_ALL;
*sim.link_filter(ProcessId::Replica(replica), ProcessId::Replica(i)) = BLOCK_ALL;
}
}
}
#[test]
fn test_basic_packet_delivery() {
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
packet_loss_probability: 0.0,
replay_probability: 0.0,
link_capacity: 64,
node_count: 3,
client_count: 1,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
let msg = create_test_message();
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), msg);
// At tick 0, packet not ready (delay >= 1)
assert!(sim.step().is_empty());
// Advance ticks until delivery (exponential distribution, so may need more than 1)
for _ in 0..20 {
sim.tick();
}
let delivered = sim.step();
assert_eq!(delivered.len(), 1);
assert_eq!(delivered[0].from, ProcessId::Replica(0));
assert_eq!(delivered[0].to, ProcessId::Replica(1));
}
#[test]
fn test_partition_drops_not_buffers() {
// Verify that packets sent during a partition are dropped, not buffered
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
node_count: 3,
client_count: 0,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
isolate_replica(&mut sim, 1, 3);
let msg = create_test_message();
// Send packet during partition
sim.submit(
ProcessId::Replica(0),
ProcessId::Replica(1),
msg.deep_copy(),
);
for _ in 0..20 {
sim.tick();
sim.step(); // drain and drop
}
// Clear partition
sim.clear_partition();
// The old packet should NOT be delivered — it was dropped
let delivered = sim.step();
assert!(delivered.is_empty());
// New packet should work after partition clears
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), msg);
for _ in 0..20 {
sim.tick();
}
let delivered = sim.step();
assert_eq!(delivered.len(), 1);
assert_eq!(delivered[0].to, ProcessId::Replica(1));
}
#[test]
fn test_clog_unclog() {
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
node_count: 2,
client_count: 0,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
let msg = create_test_message();
// Clog the link
sim.clog(ProcessId::Replica(0), ProcessId::Replica(1));
// Submit a packet
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), msg);
for _ in 0..20 {
sim.tick();
}
// Should not be delivered (clogged)
assert!(sim.step().is_empty());
// Packet should still be in flight (buffered, not dropped)
assert_eq!(sim.packets_in_flight(), 1);
// Unclog
sim.unclog(ProcessId::Replica(0), ProcessId::Replica(1));
// Now it should deliver
let delivered = sim.step();
assert_eq!(delivered.len(), 1);
}
#[test]
fn test_auto_partition_lifecycle() {
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
partition_probability: 1.0, // always partition
unpartition_probability: 1.0, // always unpartition
partition_stability: 5, // partition lasts at least 5 ticks
unpartition_stability: 3, // unpartition lasts at least 3 ticks
partition_mode: PartitionMode::IsolateSingle,
node_count: 3,
client_count: 0,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
// Initially no partition
assert!(!sim.auto_partition_active);
// Initial grace period: unpartition_stability = 3 ticks of guaranteed connectivity
for _ in 0..3 {
sim.tick();
assert!(
!sim.auto_partition_active,
"should not partition during initial grace period"
);
}
// After grace period, should partition (probability = 1.0)
sim.tick();
assert!(sim.auto_partition_active);
let disabled_count = sim.links.iter().filter(|l| l.filter.is_empty()).count();
assert!(disabled_count > 0, "partition should disable some links");
// Partition should be stable for 5 ticks
for _ in 0..5 {
sim.tick();
assert!(sim.auto_partition_active, "partition should be stable");
}
// After stability period, should unpartition (probability = 1.0)
sim.tick();
assert!(!sim.auto_partition_active);
assert!(sim.links.iter().all(|l| l.filter == ALLOW_ALL));
// Unpartition should be stable for 3 ticks
for _ in 0..3 {
sim.tick();
assert!(!sim.auto_partition_active, "unpartition should be stable");
}
// After unpartition stability, should partition again
sim.tick();
assert!(sim.auto_partition_active);
}
#[test]
fn test_loss_at_delivery_time() {
// With 100% loss, packets should be enqueued but dropped at delivery
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
packet_loss_probability: 1.0,
node_count: 2,
client_count: 0,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
let msg = create_test_message();
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), msg);
// Packet should be enqueued
assert_eq!(sim.packets_in_flight(), 1);
for _ in 0..20 {
sim.tick();
}
// Packet should be dequeued and dropped at delivery
let delivered = sim.step();
assert!(delivered.is_empty());
assert_eq!(sim.packets_in_flight(), 0);
}
#[test]
fn test_command_level_filtering() {
// Test that per-command filtering works: allow Ping but block Prepare
let options = PacketSimulatorOptions {
one_way_delay_min: 1,
one_way_delay_mean: 1,
packet_loss_probability: 0.0,
replay_probability: 0.0,
link_capacity: 64,
node_count: 2,
client_count: 0,
seed: 42,
..Default::default()
};
let mut sim = PacketSimulator::new(options);
// Set filter to only allow Ping on link 0->1
let filter = sim.link_filter(ProcessId::Replica(0), ProcessId::Replica(1));
*filter = EnumSet::only(Command2::Ping);
// Submit a Ping message and a Prepare message
let ping_msg = create_test_message_with_command(Command2::Ping);
let prepare_msg = create_test_message_with_command(Command2::Prepare);
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), ping_msg);
sim.submit(ProcessId::Replica(0), ProcessId::Replica(1), prepare_msg);
// Advance time so both are ready
for _ in 0..20 {
sim.tick();
}
let delivered = sim.step();
// Only the Ping should be delivered
assert_eq!(delivered.len(), 1);
assert_eq!(delivered[0].message.header().command, Command2::Ping);
// Nothing left in flight
assert_eq!(sim.packets_in_flight(), 0);
}
}