monocoque/zmq/pull_fanin.rs
1//! Fan-in PULL socket: one sink collecting results from many PUSH workers.
2//!
3//! A plain [`PullSocket`](crate::zmq::PullSocket) owns a single connection, so
4//! binding it accepts exactly one worker. `PullFanIn` binds once, accepts a
5//! pool of PUSH workers, and merges their messages into one fair-queued stream.
6//!
7//! ```text
8//! [Worker PUSH 0] --\
9//! [Worker PUSH 1] ---> [Sink / PullFanIn] --recv--> messages
10//! [Worker PUSH N] --/
11//! ```
12//!
13//! Each accepted connection gets its own reader task that drives that socket's
14//! `recv` to completion and forwards messages into a shared channel. Giving each
15//! connection a dedicated task keeps reads cancellation-safe (no half-read
16//! connection is ever abandoned) and the channel order is the fair-queue order
17//! the messages actually arrived in.
18//!
19//! The handoff is batched. A reader drains every message decoded from one kernel
20//! read with [`PullSocket::recv_batch`] and forwards it in chunks of at most
21//! `MAX_ITEM_MESSAGES` as channel items; the sink pops from a local buffer that it
22//! only refills from the channel when empty. That amortizes the cross-task channel
23//! hop and the per-message `.await` over a whole chunk, which is what the sink
24//! would otherwise pay per message. The chunk cap keeps a single kernel read from
25//! becoming one unbounded channel item: because a frozen message pins its whole
26//! 64 KiB slab page, an unbounded item let the queue retain an unbounded number of
27//! pages when the sink lagged the readers, so peak memory grew with payload size
28//! and worker count. Capping the messages per item makes the bounded channel a
29//! real bound on in-flight messages (and pinned pages) regardless of payload.
30//!
31//! The readers and the consumer share one runtime on purpose. A per-connection
32//! thread (one runtime each) was measured and is a net loss for the small-message
33//! merge rate this type is built for: at ~10M msg/s the decode is cheap and the
34//! cost is dominated by cross-core traffic (cache-line bouncing plus the atomic
35//! `Bytes` refcount drop landing on a different core than the one that created
36//! it). Keeping it on one runtime keeps that traffic on one core. Threads only
37//! pay off for large, decode-heavy messages, where the link bandwidth is the real
38//! limit anyway.
39
40use flume::{Receiver, Sender};
41use monocoque_core::options::SocketOptions;
42use monocoque_core::rt::{JoinHandle, spawn};
43use monocoque_core::rt::{TcpListener, TcpStream};
44use std::collections::VecDeque;
45use std::io;
46
47use super::PullSocket;
48
49/// Maximum number of messages a reader forwards in a single channel item.
50///
51/// `recv_batch` returns a whole kernel read, which at small payloads is hundreds
52/// of messages. Forwarding that as one item made the queue's memory bound depend
53/// on payload size: the channel bounds *item count*, so with unbounded item size
54/// the in-flight message count (and, because each frozen message pins its whole
55/// 64 KiB slab page, the retained pages) grew without limit when the sink lagged
56/// behind the readers. Chunking to a fixed message count keeps items large enough
57/// to amortize the cross-task channel hop while making [`CHANNEL_CAPACITY`] a real
58/// bound on queued messages regardless of payload size or worker count.
59const MAX_ITEM_MESSAGES: usize = 128;
60
61/// How many chunked items may queue before reader tasks wait for the sink to
62/// catch up. Combined with [`MAX_ITEM_MESSAGES`] this caps in-flight messages at
63/// roughly `CHANNEL_CAPACITY * MAX_ITEM_MESSAGES`, independent of payload size, so
64/// peak memory no longer scales with how far the readers outrun the sink.
65const CHANNEL_CAPACITY: usize = 64;
66
67/// A batch of complete multipart messages handed across the merge channel in one
68/// hop.
69type Batch = Vec<Vec<bytes::Bytes>>;
70
71/// A PULL endpoint that merges messages from a pool of PUSH workers.
72///
73/// Create one with [`bind`](Self::bind), or bind a listener yourself and call
74/// [`accept_workers`](Self::accept_workers) when you need the bound port before
75/// the workers connect (the bench peer does this to print its port).
76pub struct PullFanIn {
77 rx: Receiver<Batch>,
78 /// Messages from the last channel batch not yet handed out, drained one at a
79 /// time by `recv`/`try_recv` before the next channel hop.
80 buf: VecDeque<Vec<bytes::Bytes>>,
81 // Reader tasks are kept alive here. Dropping the handles cancels them, which
82 // is exactly what we want when the sink goes away.
83 _readers: Vec<JoinHandle<()>>,
84}
85
86impl PullFanIn {
87 /// Bind to `addr`, accept `n_workers` PUSH connections, and return the
88 /// listener alongside the ready fan-in socket.
89 ///
90 /// # Example
91 ///
92 /// ```rust,no_run
93 /// use monocoque::zmq::PullFanIn;
94 ///
95 /// # async fn example() -> std::io::Result<()> {
96 /// let (_listener, mut sink) = PullFanIn::bind("127.0.0.1:5558", 4).await?;
97 /// while let Ok(Some(result)) = sink.recv().await {
98 /// // process result
99 /// drop(result);
100 /// }
101 /// # Ok(())
102 /// # }
103 /// ```
104 pub async fn bind(
105 addr: impl monocoque_core::rt::ToSocketAddrs,
106 n_workers: usize,
107 ) -> io::Result<(TcpListener, Self)> {
108 Self::bind_with_options(addr, n_workers, SocketOptions::default()).await
109 }
110
111 /// Like [`bind`](Self::bind) but applies `options` to every worker connection.
112 pub async fn bind_with_options(
113 addr: impl monocoque_core::rt::ToSocketAddrs,
114 n_workers: usize,
115 options: SocketOptions,
116 ) -> io::Result<(TcpListener, Self)> {
117 let listener = TcpListener::bind(addr).await?;
118 let fanin = Self::accept_workers(&listener, n_workers, options).await?;
119 Ok((listener, fanin))
120 }
121
122 /// Accept `n_workers` PUSH connections on an already-bound listener.
123 ///
124 /// Useful when the bound address must be read (and announced) before the
125 /// workers are allowed to connect.
126 pub async fn accept_workers(
127 listener: &TcpListener,
128 n_workers: usize,
129 options: SocketOptions,
130 ) -> io::Result<Self> {
131 let (tx, rx) = flume::bounded(CHANNEL_CAPACITY);
132 let mut readers = Vec::with_capacity(n_workers);
133 for _ in 0..n_workers {
134 let (stream, _) = listener.accept().await?;
135 let pull = PullSocket::from_tcp_with_options(stream, options.clone()).await?;
136 readers.push(spawn(read_into_channel(pull, tx.clone())));
137 }
138 // Drop our own sender so the channel closes once every reader is done.
139 drop(tx);
140 Ok(Self {
141 rx,
142 buf: VecDeque::new(),
143 _readers: readers,
144 })
145 }
146
147 /// Receive the next message from any worker.
148 ///
149 /// Pops from the local buffer first and only waits on the channel when the
150 /// buffer is empty. Returns `Ok(None)` once every worker has disconnected and
151 /// both the buffer and channel have drained, mirroring `PullSocket::recv` on a
152 /// closed connection.
153 pub async fn recv(&mut self) -> io::Result<Option<Vec<bytes::Bytes>>> {
154 if let Some(msg) = self.buf.pop_front() {
155 return Ok(Some(msg));
156 }
157 // A channel error means every sender (reader task) has dropped, i.e. all
158 // workers disconnected; surface that as a clean end of stream.
159 match self.rx.recv_async().await {
160 Ok(batch) => {
161 self.buf.extend(batch);
162 Ok(self.buf.pop_front())
163 }
164 Err(_) => Ok(None),
165 }
166 }
167
168 /// Try to receive a message without waiting.
169 ///
170 /// Returns `Ok(None)` when nothing is buffered or queued, even if workers are
171 /// still connected. Use it to drain the sink after a `recv`.
172 pub fn try_recv(&mut self) -> io::Result<Option<Vec<bytes::Bytes>>> {
173 if let Some(msg) = self.buf.pop_front() {
174 return Ok(Some(msg));
175 }
176 // Both "empty" and "disconnected" map to "nothing to hand back now".
177 match self.rx.try_recv() {
178 Ok(batch) => {
179 self.buf.extend(batch);
180 Ok(self.buf.pop_front())
181 }
182 Err(_) => Ok(None),
183 }
184 }
185
186 /// Receive a burst of merged messages with a single `.await`.
187 ///
188 /// Returns the locally buffered messages plus every batch already queued from
189 /// the worker readers, folded into one `Vec`. Blocks for at least one message
190 /// when nothing is ready yet. Returning a burst from one `.await` amortizes
191 /// the per-await cost for throughput-bound sinks; it is the fan-in counterpart
192 /// to [`PullSocket::recv_batch`](crate::zmq::PullSocket::recv_batch).
193 ///
194 /// Returns `Ok(None)` once every worker has disconnected and nothing remains.
195 pub async fn recv_batch(&mut self) -> io::Result<Option<Vec<Vec<bytes::Bytes>>>> {
196 let mut out: Vec<Vec<bytes::Bytes>> = self.buf.drain(..).collect();
197 if out.is_empty() {
198 match self.rx.recv_async().await {
199 Ok(batch) => out = batch,
200 Err(_) => return Ok(None),
201 }
202 }
203 // Fold in any further batches already waiting, without blocking.
204 while let Ok(batch) = self.rx.try_recv() {
205 out.extend(batch);
206 }
207 Ok(Some(out))
208 }
209}
210
211/// Drive one worker connection, forwarding each kernel-read batch into the merge
212/// channel as a single item.
213///
214/// Exits when the connection closes, the worker errors, or the sink drops the
215/// receiver (so `send_async` fails). Any of those just means this worker is done.
216async fn read_into_channel(mut pull: PullSocket<TcpStream>, tx: Sender<Batch>) {
217 loop {
218 match pull.recv_batch().await {
219 Ok(Some(batch)) => {
220 // Forward in chunks of at most MAX_ITEM_MESSAGES, preserving
221 // arrival order. A read that decoded fewer messages than the cap
222 // (the common case) goes out as a single item with no extra
223 // allocation. Larger reads are split so no single item can pin an
224 // unbounded number of slab pages in the channel.
225 let mut it = batch.into_iter();
226 loop {
227 let chunk: Batch = it.by_ref().take(MAX_ITEM_MESSAGES).collect();
228 if chunk.is_empty() {
229 break;
230 }
231 if tx.send_async(chunk).await.is_err() {
232 return;
233 }
234 }
235 }
236 // Connection closed or errored: this worker has nothing more to give.
237 _ => return,
238 }
239 }
240}