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}