Skip to main content

pamoja_sim/
link.rs

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