Skip to main content

pamoja_sim/
link.rs

1//! A transport decorator that simulates a degraded radio link.
2
3use pamoja_core::{Error, Result, Transport};
4
5/// A [`Transport`] decorator that simulates an ongoing degraded link.
6///
7/// Where `Faulty` in `pamoja-loopback` fails a fixed number of upcoming sends, a
8/// `DegradedLink` models a link that stays bad, so offline-first behavior can be
9/// proven against a realistic pattern rather than a one-shot outage. It can drop a
10/// configurable fraction of sends (a lossy radio) and cycle between reachable and
11/// unreachable windows (a link that comes and goes). Both are deterministic - driven
12/// by a send counter, not a clock or randomness - so a store-and-forward drain over
13/// the link behaves the same way every run.
14///
15/// Connect and subscribe pass straight through; only [`send`](Transport::send) is
16/// degraded, since that is the path store-and-forward depends on. A degraded send
17/// returns [`Error::Transport`], which [`drain_to`](https://docs.rs/pamoja-sync)
18/// leaves buffered, in order, to retry later.
19///
20/// # Examples
21///
22/// ```
23/// use pamoja_core::Transport;
24/// use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
25/// use pamoja_sim::DegradedLink;
26///
27/// # async fn run() -> pamoja_core::Result<()> {
28/// let broker = LoopbackBroker::new();
29/// // A link that drops every second packet.
30/// let mut link = DegradedLink::new(LoopbackTransport::new(broker)).drop_every(2);
31/// link.connect().await?;
32///
33/// link.send("t", b"1").await?; // first send: delivered
34/// assert!(link.send("t", b"2").await.is_err()); // second send: dropped
35/// link.send("t", b"3").await?; // third send: delivered
36/// # Ok(())
37/// # }
38/// ```
39#[derive(Clone, Debug)]
40pub struct DegradedLink<T> {
41    inner: T,
42    drop_every: u32,
43    window: Option<(u32, u32)>,
44    sends: u32,
45}
46
47impl<T> DegradedLink<T> {
48    /// Wraps `inner` as a perfect link, until loss or intermittency is added.
49    ///
50    /// # Arguments
51    ///
52    /// * `inner` - the transport to decorate.
53    ///
54    /// # Returns
55    ///
56    /// A decorator that passes every send through until configured otherwise.
57    pub fn new(inner: T) -> Self {
58        Self {
59            inner,
60            drop_every: 0,
61            window: None,
62            sends: 0,
63        }
64    }
65
66    /// Drops one in every `n` sends, simulating a lossy link.
67    ///
68    /// # Arguments
69    ///
70    /// * `n` - drop every `n`th send; `0` disables loss.
71    ///
72    /// # Returns
73    ///
74    /// The updated link, for chaining.
75    pub fn drop_every(mut self, n: u32) -> Self {
76        self.drop_every = n;
77        self
78    }
79
80    /// Cycles between `up` reachable sends and `down` unreachable sends.
81    ///
82    /// Sends rejected during a down window return [`Error::Transport`], the same as a
83    /// real link that is temporarily out of range.
84    ///
85    /// # Arguments
86    ///
87    /// * `up` - the number of sends that succeed at the start of each cycle.
88    /// * `down` - the number of sends that fail before the cycle repeats.
89    ///
90    /// # Returns
91    ///
92    /// The updated link, for chaining.
93    pub fn intermittent(mut self, up: u32, down: u32) -> Self {
94        self.window = if up + down == 0 {
95            None
96        } else {
97            Some((up, down))
98        };
99        self
100    }
101
102    /// Unwraps the decorator, returning the inner transport.
103    ///
104    /// # Returns
105    ///
106    /// The wrapped transport.
107    pub fn into_inner(self) -> T {
108        self.inner
109    }
110
111    // Whether the current send falls in a down window of the intermittent cycle.
112    fn link_is_down(&self) -> bool {
113        match self.window {
114            Some((up, down)) => (self.sends - 1) % (up + down) >= up,
115            None => false,
116        }
117    }
118
119    // Whether the current send is the one dropped by the loss pattern.
120    fn packet_lost(&self) -> bool {
121        self.drop_every != 0 && self.sends.is_multiple_of(self.drop_every)
122    }
123}
124
125impl<T: Transport + Send> Transport for DegradedLink<T> {
126    async fn connect(&mut self) -> Result<()> {
127        self.inner.connect().await
128    }
129
130    async fn send(&mut self, topic: &str, payload: &[u8]) -> Result<()> {
131        self.sends += 1;
132        if self.link_is_down() {
133            return Err(Error::Transport("link unreachable".to_owned()));
134        }
135        if self.packet_lost() {
136            return Err(Error::Transport("packet lost on a lossy link".to_owned()));
137        }
138        self.inner.send(topic, payload).await
139    }
140
141    async fn subscribe(&mut self, topic: &str) -> Result<()> {
142        self.inner.subscribe(topic).await
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    // A transport that records the payloads it successfully sends.
151    #[derive(Default)]
152    struct CountingTransport {
153        sent: Vec<Vec<u8>>,
154    }
155
156    impl Transport for CountingTransport {
157        async fn connect(&mut self) -> Result<()> {
158            Ok(())
159        }
160
161        async fn send(&mut self, _topic: &str, payload: &[u8]) -> Result<()> {
162            self.sent.push(payload.to_vec());
163            Ok(())
164        }
165
166        async fn subscribe(&mut self, _topic: &str) -> Result<()> {
167            Ok(())
168        }
169    }
170
171    async fn send_seq(link: &mut DegradedLink<CountingTransport>, count: u8) -> usize {
172        let mut errors = 0;
173        for i in 1..=count {
174            if link.send("t", &[i]).await.is_err() {
175                errors += 1;
176            }
177        }
178        errors
179    }
180
181    #[tokio::test]
182    async fn a_perfect_link_passes_every_send() {
183        let mut link = DegradedLink::new(CountingTransport::default());
184        assert_eq!(send_seq(&mut link, 3).await, 0);
185        assert_eq!(link.into_inner().sent.len(), 3);
186    }
187
188    #[tokio::test]
189    async fn loss_drops_every_nth_send() {
190        let inner = CountingTransport::default();
191        let mut link = DegradedLink::new(inner).drop_every(3);
192        assert_eq!(send_seq(&mut link, 6).await, 2); // sends 3 and 6 are dropped
193        assert_eq!(
194            link.into_inner().sent,
195            vec![vec![1], vec![2], vec![4], vec![5]]
196        );
197    }
198
199    #[tokio::test]
200    async fn intermittency_cycles_between_up_and_down() {
201        let inner = CountingTransport::default();
202        let mut link = DegradedLink::new(inner).intermittent(2, 1);
203        // Period of three: two through, one rejected, repeating.
204        assert_eq!(send_seq(&mut link, 6).await, 2); // sends 3 and 6 fail
205        assert_eq!(
206            link.into_inner().sent,
207            vec![vec![1], vec![2], vec![4], vec![5]]
208        );
209    }
210
211    #[tokio::test]
212    async fn a_retry_after_a_drop_eventually_gets_through() {
213        // The same payload, retried, advances the counter until a send lands.
214        let inner = CountingTransport::default();
215        let mut link = DegradedLink::new(inner).intermittent(1, 1);
216        assert!(link.send("t", b"x").await.is_ok()); // up
217        assert!(link.send("t", b"x").await.is_err()); // down
218        assert!(link.send("t", b"x").await.is_ok()); // up again
219        assert_eq!(link.into_inner().sent.len(), 2);
220    }
221}