Skip to content

Commit 44a0d4b

Browse files
committed
refactor(swim): split transport into per-impl modules, add UDP transport
Extract `InMemoryTransport` into its own file and add a real `UdpTransport` alongside it, with the shared `Transport` trait and `TransportFabric` living in a `transport/mod.rs` hub. The old monolithic `transport.rs` held the trait, the in-memory fabric, and placeholder comments for UDP; keeping them together made the file grow as soon as UDP landed. The new layout mirrors the established one-concern-per-file rule: transport/mod.rs — re-exports + shared trait transport/in_memory.rs — test fabric (mpsc channels, drop injection) transport/udp.rs — production UDP transport (tokio UdpSocket) `UdpTransport` is now re-exported from the `swim` public API so cluster startup and integration tests can construct one without reaching into internal modules.
1 parent 95f8260 commit 44a0d4b

4 files changed

Lines changed: 241 additions & 41 deletions

File tree

nodedb-cluster/src/swim/detector/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ pub use probe_round::{ProbeOutcome, ProbeRound};
1717
pub use runner::FailureDetector;
1818
pub use scheduler::ProbeScheduler;
1919
pub use suspicion::SuspicionTimer;
20-
pub use transport::{InMemoryTransport, Transport, TransportFabric};
20+
pub use transport::{InMemoryTransport, Transport, TransportFabric, UdpTransport};

nodedb-cluster/src/swim/detector/transport.rs renamed to nodedb-cluster/src/swim/detector/transport/in_memory.rs

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,16 @@
1-
//! SWIM transport abstraction.
2-
//!
3-
//! The detector talks to the network exclusively through the [`Transport`]
4-
//! trait. Two impls exist in the crate:
5-
//!
6-
//! 1. [`InMemoryTransport`] — a tokio-mpsc fabric used by every E-γ unit
7-
//! test. Supports per-edge drop and partition injection so tests can
8-
//! deterministically simulate unreachable peers.
9-
//! 2. The real UDP transport — lands in E-ε, not in this file.
10-
//!
11-
//! The trait is `Send + Sync` and its methods are `async`. Errors are
12-
//! typed [`SwimError::TransportClosed`] variants so callers never see
13-
//! raw `io::Error`.
14-
15-
use std::collections::HashMap;
1+
//! Test-only tokio-mpsc fabric implementing [`super::Transport`].
2+
3+
use std::collections::{HashMap, HashSet};
164
use std::net::SocketAddr;
175
use std::sync::Arc;
186

197
use async_trait::async_trait;
208
use tokio::sync::{Mutex, mpsc};
219

10+
use super::Transport;
2211
use crate::swim::error::SwimError;
2312
use crate::swim::wire::SwimMessage;
2413

25-
/// Abstract SWIM transport. Implementations may be unreliable (UDP-like);
26-
/// the detector assumes nothing about ordering or delivery guarantees.
27-
#[async_trait]
28-
pub trait Transport: Send + Sync {
29-
/// Send a single SWIM datagram to `to`. Errors indicate the transport
30-
/// itself is broken, not that the peer is unreachable — an unreachable
31-
/// peer is modelled as a silent drop.
32-
async fn send(&self, to: SocketAddr, msg: SwimMessage) -> Result<(), SwimError>;
33-
34-
/// Block until the next inbound datagram is available. Returns
35-
/// [`SwimError::TransportClosed`] when the transport is shut down.
36-
async fn recv(&self) -> Result<(SocketAddr, SwimMessage), SwimError>;
37-
38-
/// The local bind address — returned so callers can include it in
39-
/// outgoing messages without plumbing the address through separately.
40-
fn local_addr(&self) -> SocketAddr;
41-
}
42-
4314
/// Test-only tokio-mpsc fabric that hosts multiple [`InMemoryTransport`]
4415
/// endpoints sharing the same address space.
4516
#[derive(Debug, Default)]
@@ -56,7 +27,7 @@ struct FabricInner {
5627
/// Inbound queue per bound address.
5728
inboxes: HashMap<SocketAddr, mpsc::Sender<(SocketAddr, SwimMessage)>>,
5829
/// Set of (from, to) pairs whose datagrams are silently dropped.
59-
dropped_edges: std::collections::HashSet<(SocketAddr, SocketAddr)>,
30+
dropped_edges: HashSet<(SocketAddr, SocketAddr)>,
6031
}
6132

6233
impl TransportFabric {
@@ -69,7 +40,7 @@ impl TransportFabric {
6940

7041
/// Bind a new endpoint on the fabric. Panics only if `addr` is already
7142
/// bound in the fabric (test-only assertion — production transport
72-
/// lives in E-ε).
43+
/// is [`super::UdpTransport`]).
7344
pub async fn bind(self: &Arc<Self>, addr: SocketAddr) -> InMemoryTransport {
7445
let (tx, rx) = mpsc::channel(1024);
7546
let mut guard = self.inner.lock().await;
@@ -171,7 +142,6 @@ mod tests {
171142
let _b = fab.bind(addr(7001)).await;
172143
fab.drop_edge(addr(7000), addr(7001)).await;
173144
a.send(addr(7001), ping()).await.expect("send");
174-
// Recv should time out — nothing delivered.
175145
let got = tokio::time::timeout(std::time::Duration::from_millis(20), _b.recv()).await;
176146
assert!(got.is_err(), "dropped edge should not deliver");
177147
}
@@ -188,10 +158,7 @@ mod tests {
188158
let fab = TransportFabric::new();
189159
let b = fab.bind(addr(7001)).await;
190160
fab.remove(addr(7001)).await;
191-
// The bound transport still holds its Receiver — sender half is
192-
// removed from the fabric, so future sends from other endpoints
193-
// will now silently drop. The existing inbox is still drainable.
194-
let _ = b; // silence unused
161+
let _ = b;
195162
}
196163

197164
#[tokio::test]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//! SWIM transport abstraction.
2+
//!
3+
//! The detector talks to the network exclusively through the [`Transport`]
4+
//! trait. Two production-facing impls exist:
5+
//!
6+
//! 1. [`in_memory::InMemoryTransport`] — a tokio-mpsc fabric used by every
7+
//! unit test. Supports per-edge drop and partition injection so tests
8+
//! can deterministically simulate unreachable peers.
9+
//! 2. [`udp::UdpTransport`] — the real wire-level transport that binds a
10+
//! `tokio::net::UdpSocket` and framing-encodes every datagram via
11+
//! [`crate::swim::wire::encode`].
12+
//!
13+
//! The trait is `Send + Sync` and its methods are `async`. Errors are
14+
//! typed [`SwimError`] variants so callers never see raw `io::Error`.
15+
16+
pub mod in_memory;
17+
pub mod udp;
18+
19+
use std::net::SocketAddr;
20+
21+
use async_trait::async_trait;
22+
23+
use crate::swim::error::SwimError;
24+
use crate::swim::wire::SwimMessage;
25+
26+
pub use in_memory::{InMemoryTransport, TransportFabric};
27+
pub use udp::UdpTransport;
28+
29+
/// Abstract SWIM transport. Implementations may be unreliable (UDP-like);
30+
/// the detector assumes nothing about ordering or delivery guarantees.
31+
#[async_trait]
32+
pub trait Transport: Send + Sync {
33+
/// Send a single SWIM datagram to `to`. Errors indicate the transport
34+
/// itself is broken, not that the peer is unreachable — an unreachable
35+
/// peer is modelled as a silent drop.
36+
async fn send(&self, to: SocketAddr, msg: SwimMessage) -> Result<(), SwimError>;
37+
38+
/// Block until the next inbound datagram is available. Returns
39+
/// [`SwimError::TransportClosed`] when the transport is shut down.
40+
async fn recv(&self) -> Result<(SocketAddr, SwimMessage), SwimError>;
41+
42+
/// The local bind address — returned so callers can include it in
43+
/// outgoing messages without plumbing the address through separately.
44+
fn local_addr(&self) -> SocketAddr;
45+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
//! Real UDP transport for SWIM.
2+
//!
3+
//! Binds a single `tokio::net::UdpSocket` and implements [`super::Transport`]
4+
//! by framing every outbound `SwimMessage` with the zerompk wire codec
5+
//! and parsing every inbound datagram the same way. Malformed inbound
6+
//! bytes surface as [`SwimError::Decode`] but do not close the socket —
7+
//! the failure detector's recv loop treats non-`TransportClosed` errors
8+
//! as transient and keeps running.
9+
//!
10+
//! Datagram size is capped by [`RECV_BUF_BYTES`], which must be large
11+
//! enough to hold a zerompk-encoded `SwimMessage` at the configured
12+
//! `max_piggyback` budget. 64 KiB is the IPv4 UDP maximum and is
13+
//! comfortably larger than any realistic SWIM payload.
14+
15+
use std::net::SocketAddr;
16+
use std::sync::Arc;
17+
18+
use async_trait::async_trait;
19+
use tokio::net::UdpSocket;
20+
use tokio::sync::Mutex;
21+
22+
use super::Transport;
23+
use crate::swim::error::SwimError;
24+
use crate::swim::wire::{self, SwimMessage};
25+
26+
/// IPv4 UDP maximum datagram size. zerompk-encoded SWIM messages with a
27+
/// 6-entry piggyback fit in well under 2 KiB, so 64 KiB is abundant.
28+
pub const RECV_BUF_BYTES: usize = 65_536;
29+
30+
/// SWIM datagram transport backed by a real UDP socket.
31+
#[derive(Debug)]
32+
pub struct UdpTransport {
33+
socket: Arc<UdpSocket>,
34+
local_addr: SocketAddr,
35+
/// Serializes access to the recv buffer. `recv_from` takes `&self`
36+
/// on `UdpSocket`, but we need a reusable buffer without allocating
37+
/// 64 KiB per datagram; the mutex guards that buffer.
38+
recv_buf: Mutex<Vec<u8>>,
39+
}
40+
41+
impl UdpTransport {
42+
/// Bind to `addr`. Passing `127.0.0.1:0` picks an ephemeral port,
43+
/// which [`UdpTransport::local_addr`] then reports.
44+
pub async fn bind(addr: SocketAddr) -> Result<Self, SwimError> {
45+
let socket = UdpSocket::bind(addr).await.map_err(|e| SwimError::Encode {
46+
detail: format!("udp bind {addr}: {e}"),
47+
})?;
48+
let local_addr = socket.local_addr().map_err(|e| SwimError::Encode {
49+
detail: format!("udp local_addr: {e}"),
50+
})?;
51+
Ok(Self {
52+
socket: Arc::new(socket),
53+
local_addr,
54+
recv_buf: Mutex::new(vec![0u8; RECV_BUF_BYTES]),
55+
})
56+
}
57+
}
58+
59+
#[async_trait]
60+
impl Transport for UdpTransport {
61+
async fn send(&self, to: SocketAddr, msg: SwimMessage) -> Result<(), SwimError> {
62+
let bytes = wire::encode(&msg)?;
63+
// UDP send_to is atomic per datagram; partial sends aren't a
64+
// thing, so we only have to handle the error case.
65+
self.socket
66+
.send_to(&bytes, to)
67+
.await
68+
.map(|_| ())
69+
.map_err(|e| SwimError::Encode {
70+
detail: format!("udp send_to {to}: {e}"),
71+
})
72+
}
73+
74+
async fn recv(&self) -> Result<(SocketAddr, SwimMessage), SwimError> {
75+
let mut buf = self.recv_buf.lock().await;
76+
let (n, from) =
77+
self.socket
78+
.recv_from(&mut buf[..])
79+
.await
80+
.map_err(|e| SwimError::Decode {
81+
detail: format!("udp recv_from: {e}"),
82+
})?;
83+
let msg = wire::decode(&buf[..n])?;
84+
Ok((from, msg))
85+
}
86+
87+
fn local_addr(&self) -> SocketAddr {
88+
self.local_addr
89+
}
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
use crate::swim::incarnation::Incarnation;
96+
use crate::swim::wire::{Ack, Ping, ProbeId};
97+
use nodedb_types::NodeId;
98+
use std::net::{IpAddr, Ipv4Addr};
99+
use std::time::Duration;
100+
101+
fn any_loopback() -> SocketAddr {
102+
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
103+
}
104+
105+
#[tokio::test]
106+
async fn bind_and_report_local_addr() {
107+
let t = UdpTransport::bind(any_loopback()).await.expect("bind");
108+
assert_eq!(t.local_addr().ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
109+
assert_ne!(t.local_addr().port(), 0);
110+
}
111+
112+
#[tokio::test]
113+
async fn send_and_recv_roundtrip_between_real_sockets() {
114+
let a = UdpTransport::bind(any_loopback()).await.expect("bind a");
115+
let b = UdpTransport::bind(any_loopback()).await.expect("bind b");
116+
let ping = SwimMessage::Ping(Ping {
117+
probe_id: ProbeId::new(42),
118+
from: NodeId::new("a"),
119+
incarnation: Incarnation::new(3),
120+
piggyback: vec![],
121+
});
122+
a.send(b.local_addr(), ping.clone()).await.expect("send");
123+
let (from, msg) = tokio::time::timeout(Duration::from_secs(1), b.recv())
124+
.await
125+
.expect("recv timed out")
126+
.expect("recv ok");
127+
assert_eq!(from, a.local_addr());
128+
assert_eq!(msg, ping);
129+
}
130+
131+
#[tokio::test]
132+
async fn recv_decodes_ack_variant() {
133+
let a = UdpTransport::bind(any_loopback()).await.expect("bind a");
134+
let b = UdpTransport::bind(any_loopback()).await.expect("bind b");
135+
let ack = SwimMessage::Ack(Ack {
136+
probe_id: ProbeId::new(1),
137+
from: NodeId::new("b"),
138+
incarnation: Incarnation::new(7),
139+
piggyback: vec![],
140+
});
141+
a.send(b.local_addr(), ack.clone()).await.expect("send");
142+
let (_from, msg) = tokio::time::timeout(Duration::from_secs(1), b.recv())
143+
.await
144+
.expect("recv timed out")
145+
.expect("recv ok");
146+
assert_eq!(msg, ack);
147+
}
148+
149+
#[tokio::test]
150+
async fn decode_error_on_garbage_datagram() {
151+
// Bind one socket, send raw garbage to it from a second
152+
// loopback socket, then call `recv` through the transport
153+
// wrapper. The malformed bytes must surface as SwimError::Decode
154+
// rather than close the socket.
155+
let victim = UdpTransport::bind(any_loopback()).await.expect("bind");
156+
let sender = tokio::net::UdpSocket::bind(any_loopback())
157+
.await
158+
.expect("sender bind");
159+
sender
160+
.send_to(&[0xff_u8; 8], victim.local_addr())
161+
.await
162+
.expect("send garbage");
163+
let err = tokio::time::timeout(Duration::from_secs(1), victim.recv())
164+
.await
165+
.expect("recv timed out")
166+
.expect_err("decode should fail");
167+
assert!(matches!(err, SwimError::Decode { .. }));
168+
169+
// Follow up with a valid datagram to prove the socket still
170+
// works after a bad one.
171+
let sender_transport = UdpTransport::bind(any_loopback()).await.expect("bind");
172+
let ping = SwimMessage::Ping(Ping {
173+
probe_id: ProbeId::new(1),
174+
from: NodeId::new("x"),
175+
incarnation: Incarnation::ZERO,
176+
piggyback: vec![],
177+
});
178+
sender_transport
179+
.send(victim.local_addr(), ping.clone())
180+
.await
181+
.expect("send valid");
182+
let (_from, msg) = tokio::time::timeout(Duration::from_secs(1), victim.recv())
183+
.await
184+
.expect("recv timed out")
185+
.expect("recv ok");
186+
assert_eq!(msg, ping);
187+
}
188+
}

0 commit comments

Comments
 (0)