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}