Skip to main content

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}