Skip to main content

scatter_gather/
scatter_gather.rs

1//! A **scatter/gather aggregator** on a single compio runtime: each client query
2//! is fanned out to several backend *shards* at once, and their replies are
3//! merged into one answer. One [`Pool`] per shard supplies the connections.
4//!
5//! This is the other shape a connection pool takes in a real service. The HTTP
6//! proxy reuses *one* upstream per request; here every request touches *all* the
7//! shards, concurrently, and each shard has its own small pool of kept-alive
8//! connections. When a request arrives the aggregator [`compio::runtime::spawn`]s
9//! one task per shard — each leasing from that shard's [`LocalPool`] — then awaits
10//! them and stitches the results together. Several pools, many in-flight leases,
11//! structured fan-out per request, all cooperatively scheduled on one thread with
12//! no [`std::thread::spawn`] anywhere.
13//!
14//! To make the concurrency legible each shard sleeps `DELAY_MS` before replying
15//! (standing in for real backend work). With N shards the gather still finishes in
16//! roughly one `DELAY_MS`, not N of them — the reported per-request time proves the
17//! scatter runs the shards in parallel rather than one after another.
18//!
19//! Everything is in-process: the shard backends are tasks on ephemeral loopback
20//! ports, so the example needs nothing external.
21//!
22//! ```text
23//! cargo run --release --example scatter_gather -- [LISTEN] [SHARDS] [CAPACITY]
24//!   LISTEN     aggregator bind address     default 127.0.0.1:9000
25//!   SHARDS     number of backend shards    default 4
26//!   CAPACITY   pooled conns per shard       default 8
27//!   DELAY_MS=<n>  simulated per-shard latency (default 5)
28//!
29//! # send a query line, read the merged answer:
30//! printf 'needle\n' | nc -q1 127.0.0.1 9000
31//! ```
32
33use std::{io, net::SocketAddr, rc::Rc, time::Duration, time::Instant};
34
35use compio::{
36    BufResult,
37    io::{AsyncRead, AsyncWriteExt},
38    net::{TcpListener, TcpStream},
39    runtime::Runtime,
40};
41use compio_pool::{LocalPool, ManageConnection, Pool};
42
43/// A newline-framed connection: one `io_uring`-bound stream, a read buffer, and a
44/// health flag for the pool. Used for the client, the shard clients, and the
45/// shard backends alike.
46struct Line {
47    stream: TcpStream,
48    buf: Vec<u8>,
49    healthy: bool,
50}
51
52impl Line {
53    fn new(stream: TcpStream) -> Self {
54        Self {
55            stream,
56            buf: Vec::with_capacity(1024),
57            healthy: true,
58        }
59    }
60
61    /// Read one `\n`-terminated line, trimmed of its line ending. `Ok(None)` means
62    /// the peer closed.
63    async fn read_line(&mut self) -> io::Result<Option<String>> {
64        loop {
65            if let Some(pos) = self.buf.iter().position(|&b| b == b'\n') {
66                let line: Vec<u8> = self.buf.drain(..=pos).collect();
67                let text = String::from_utf8_lossy(&line[..line.len() - 1])
68                    .trim_end_matches('\r')
69                    .to_string();
70                return Ok(Some(text));
71            }
72            // compio reads into spare capacity and grows the length; keep room.
73            if self.buf.len() == self.buf.capacity() {
74                self.buf.reserve(1024);
75            }
76            let taken = std::mem::take(&mut self.buf);
77            let BufResult(res, b) = self.stream.read(taken).await;
78            self.buf = b;
79            match res {
80                Ok(0) => return Ok(None),
81                Ok(_) => {}
82                Err(e) => return Err(e),
83            }
84        }
85    }
86
87    /// Write one line, appending the newline.
88    async fn write_line(&mut self, text: &str) -> io::Result<()> {
89        let mut bytes = text.as_bytes().to_vec();
90        bytes.push(b'\n');
91        let BufResult(res, _) = self.stream.write_all(bytes).await;
92        res
93    }
94}
95
96/// Opens pooled client connections to one shard backend.
97struct Shard {
98    addr: SocketAddr,
99}
100
101impl ManageConnection for Shard {
102    type Connection = Line;
103    type Error = io::Error;
104
105    async fn connect(&self) -> io::Result<Line> {
106        let stream = TcpStream::connect(self.addr).await?;
107        stream.set_nodelay(true)?;
108        Ok(Line::new(stream))
109    }
110
111    async fn is_valid(&self, conn: &mut Line) -> io::Result<()> {
112        if conn.healthy {
113            Ok(())
114        } else {
115            Err(io::Error::new(io::ErrorKind::BrokenPipe, "shard conn marked dead"))
116        }
117    }
118
119    fn has_broken(&self, conn: &mut Line) -> bool {
120        !conn.healthy
121    }
122}
123
124fn main() -> io::Result<()> {
125    let listen: SocketAddr = std::env::args()
126        .nth(1)
127        .unwrap_or_else(|| "127.0.0.1:9000".into())
128        .parse()
129        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("bad listen addr: {e}")))?;
130    let shard_count: usize = std::env::args()
131        .nth(2)
132        .and_then(|s| s.parse().ok())
133        .unwrap_or(4);
134    let capacity: u32 = std::env::args()
135        .nth(3)
136        .and_then(|s| s.parse().ok())
137        .unwrap_or(8);
138    let delay_ms: u64 = std::env::var("DELAY_MS")
139        .ok()
140        .and_then(|s| s.parse().ok())
141        .unwrap_or(5);
142
143    let runtime = Runtime::builder().build().expect("build compio runtime");
144    runtime.block_on(async move {
145        // Start the in-process shard backends and build one pool per shard.
146        let mut shard_pools = Vec::with_capacity(shard_count);
147        for id in 0..shard_count {
148            let any: SocketAddr = "127.0.0.1:0".parse().unwrap();
149            let listener = TcpListener::bind(any).await?;
150            let addr = listener.local_addr()?;
151            compio::runtime::spawn(shard_loop(listener, id, delay_ms)).detach();
152
153            let pool = Pool::builder().max_size(capacity).build(Shard { addr });
154            shard_pools.push(pool.local());
155        }
156        let shards = Rc::new(shard_pools);
157
158        let server = TcpListener::bind(listen).await?;
159        println!(
160            "scatter_gather on {listen}: {shard_count} shards x up to {capacity} pooled conns \
161             each, {delay_ms}ms/shard work"
162        );
163        println!("try: printf 'needle\\n' | nc -q1 {listen}");
164
165        loop {
166            let Ok((stream, _peer)) = server.accept().await else {
167                continue;
168            };
169            let _ = stream.set_nodelay(true);
170            compio::runtime::spawn(handle_client(shards.clone(), stream)).detach();
171        }
172    })
173}
174
175/// Serve one client connection: for each query line, scatter it to every shard
176/// concurrently, gather the replies, and send back the merged line.
177async fn handle_client(shards: Rc<Vec<LocalPool<Shard>>>, client_stream: TcpStream) {
178    let mut client = Line::new(client_stream);
179    loop {
180        let query = match client.read_line().await {
181            Ok(Some(q)) if !q.is_empty() => q,
182            Ok(Some(_)) => continue, // blank line, wait for the next
183            _ => return,             // closed or errored
184        };
185
186        // Scatter: one task per shard, each leasing from that shard's own pool.
187        // They run concurrently on this one runtime while each awaits its shard.
188        let started = Instant::now();
189        let mut tasks = Vec::with_capacity(shards.len());
190        for pool in shards.iter() {
191            let pool = pool.clone();
192            let q = query.clone();
193            tasks.push(compio::runtime::spawn(query_shard(pool, q)));
194        }
195
196        // Gather: await the tasks in shard order so the merged line is stable.
197        let mut parts = Vec::with_capacity(tasks.len());
198        for (id, task) in tasks.into_iter().enumerate() {
199            match task.await {
200                Ok(Some(reply)) => parts.push(reply),
201                _ => parts.push(format!("shard{id}=ERR")),
202            }
203        }
204        let elapsed = started.elapsed();
205
206        let merged = format!(
207            "{} | {} shards in {:.1}ms",
208            parts.join(" "),
209            parts.len(),
210            elapsed.as_secs_f64() * 1000.0,
211        );
212        if client.write_line(&merged).await.is_err() {
213            return;
214        }
215    }
216}
217
218/// Lease one connection from a shard's pool, send the query, read the reply, and
219/// return the connection to the pool. `None` on any failure (and the connection
220/// is marked dead so the pool discards it).
221async fn query_shard(pool: LocalPool<Shard>, query: String) -> Option<String> {
222    let mut conn = match pool.get().await {
223        Ok(conn) => conn,
224        Err(_) => return None,
225    };
226    if conn.write_line(&query).await.is_err() {
227        conn.healthy = false;
228        return None;
229    }
230    match conn.read_line().await {
231        Ok(Some(reply)) => Some(reply),
232        _ => {
233            conn.healthy = false;
234            None
235        }
236    }
237}
238
239/// One shard backend: accept forever, serve each connection on its own task.
240async fn shard_loop(listener: TcpListener, id: usize, delay_ms: u64) {
241    loop {
242        let Ok((stream, _peer)) = listener.accept().await else {
243            continue;
244        };
245        let _ = stream.set_nodelay(true);
246        compio::runtime::spawn(shard_conn(stream, id, delay_ms)).detach();
247    }
248}
249
250/// Answer each query with this shard's contribution after a simulated delay.
251async fn shard_conn(stream: TcpStream, id: usize, delay_ms: u64) {
252    let mut conn = Line::new(stream);
253    loop {
254        let query = match conn.read_line().await {
255            Ok(Some(q)) => q,
256            _ => return,
257        };
258        if delay_ms > 0 {
259            compio::time::sleep(Duration::from_millis(delay_ms)).await;
260        }
261        // A deterministic, shard-specific "result" so the merged answer varies by
262        // shard: the query's byte sum folded into this shard's bucket count.
263        let buckets = 10 + id as u64;
264        let hits = query.bytes().map(u64::from).sum::<u64>() % buckets;
265        if conn.write_line(&format!("shard{id}={hits}")).await.is_err() {
266            return;
267        }
268    }
269}