Skip to main content

monocoque/zmq/
push_fanout.rs

1//! Fan-out PUSH socket: one ventilator spreading work across many PULL workers.
2//!
3//! A plain [`PushSocket`](crate::zmq::PushSocket) owns a single connection, so
4//! binding it accepts exactly one worker. `PushFanOut` binds once, accepts a
5//! whole pool of PULL workers, and hands each `send` to the next worker in turn.
6//! That is the ZMQ PUSH load-balancing rule: every message goes to exactly one
7//! worker, and consecutive messages rotate through the pool.
8//!
9//! ```text
10//! [Ventilator / PushFanOut] --round-robin--> [Worker PULL 0]
11//!                                        \--> [Worker PULL 1]
12//!                                         \-> [Worker PULL N]
13//! ```
14//!
15//! Workers connect with an ordinary `PullSocket::connect`, so the worker side
16//! needs no special type.
17
18use monocoque_core::options::SocketOptions;
19use monocoque_core::rt::{TcpListener, TcpStream};
20use std::io;
21
22use super::PushSocket;
23
24/// A PUSH endpoint that distributes messages across a pool of PULL workers.
25///
26/// Create one with [`bind`](Self::bind), or bind a listener yourself and call
27/// [`accept_workers`](Self::accept_workers) when you need the bound port before
28/// the workers connect (the bench peer does this to print its port).
29pub struct PushFanOut {
30    workers: Vec<PushSocket<TcpStream>>,
31    next: usize,
32}
33
34impl PushFanOut {
35    /// Bind to `addr`, accept `n_workers` PULL connections, and return the
36    /// listener alongside the ready fan-out socket.
37    ///
38    /// The listener is returned so the caller can keep accepting late workers
39    /// with [`accept`](Self::accept) if it wants to grow the pool.
40    ///
41    /// # Example
42    ///
43    /// ```rust,no_run
44    /// use monocoque::zmq::PushFanOut;
45    /// use bytes::Bytes;
46    ///
47    /// # async fn example() -> std::io::Result<()> {
48    /// let (_listener, mut vent) = PushFanOut::bind("127.0.0.1:5557", 4).await?;
49    /// for i in 0..100 {
50    ///     vent.send(vec![Bytes::from(format!("task-{i}"))]).await?;
51    /// }
52    /// # Ok(())
53    /// # }
54    /// ```
55    pub async fn bind(
56        addr: impl monocoque_core::rt::ToSocketAddrs,
57        n_workers: usize,
58    ) -> io::Result<(TcpListener, Self)> {
59        Self::bind_with_options(addr, n_workers, SocketOptions::default()).await
60    }
61
62    /// Like [`bind`](Self::bind) but applies `options` to every worker connection.
63    pub async fn bind_with_options(
64        addr: impl monocoque_core::rt::ToSocketAddrs,
65        n_workers: usize,
66        options: SocketOptions,
67    ) -> io::Result<(TcpListener, Self)> {
68        let listener = TcpListener::bind(addr).await?;
69        let fanout = Self::accept_workers(&listener, n_workers, options).await?;
70        Ok((listener, fanout))
71    }
72
73    /// Accept `n_workers` PULL connections on an already-bound listener.
74    ///
75    /// Useful when the bound address must be read (and announced) before the
76    /// workers are allowed to connect.
77    pub async fn accept_workers(
78        listener: &TcpListener,
79        n_workers: usize,
80        options: SocketOptions,
81    ) -> io::Result<Self> {
82        let mut workers = Vec::with_capacity(n_workers);
83        for _ in 0..n_workers {
84            let (stream, _) = listener.accept().await?;
85            workers.push(PushSocket::from_tcp_with_options(stream, options.clone()).await?);
86        }
87        Ok(Self { workers, next: 0 })
88    }
89
90    /// Accept one more worker on `listener` and add it to the pool.
91    pub async fn accept(&mut self, listener: &TcpListener) -> io::Result<()> {
92        let (stream, _) = listener.accept().await?;
93        self.workers.push(PushSocket::from_tcp(stream).await?);
94        Ok(())
95    }
96
97    /// Number of workers currently in the pool.
98    #[inline]
99    pub fn len(&self) -> usize {
100        self.workers.len()
101    }
102
103    /// True when no workers remain.
104    #[inline]
105    pub fn is_empty(&self) -> bool {
106        self.workers.is_empty()
107    }
108
109    /// Send one message to the next worker in round-robin order.
110    ///
111    /// Workers already known to be disconnected are skipped (and dropped from the
112    /// pool) before the message is handed over, so a send that fails mid-flight
113    /// only fails the current call; the failed worker is routed around from the
114    /// next send onward. The call only errors when no live worker remains.
115    ///
116    /// The message bodies stay zero-copy (`Bytes` are refcounted) and the healthy
117    /// path moves `msg` straight into the chosen worker, so it adds no per-message
118    /// allocation over a plain `PushSocket::send`.
119    pub async fn send(&mut self, msg: Vec<bytes::Bytes>) -> io::Result<()> {
120        // Advance to the next worker that still looks connected, dropping any
121        // known-dead ones on the way. This needs no copy of `msg`, so the common
122        // all-healthy case moves the message in without an extra allocation.
123        while !self.workers.is_empty() {
124            let idx = self.next % self.workers.len();
125            if !self.workers[idx].is_connected() {
126                // Drop the dead worker; `idx` now indexes whatever shifted into
127                // its place, so leave `next` pointing there.
128                self.workers.remove(idx);
129                self.next = idx;
130                continue;
131            }
132
133            return match self.workers[idx].send(msg).await {
134                Ok(()) => {
135                    self.next = idx + 1;
136                    Ok(())
137                }
138                Err(e) => {
139                    // The send failed: the worker is disconnected, or it was
140                    // poisoned by a cancelled write (a poisoned socket keeps its
141                    // stream, so `is_connected()` alone would not catch it).
142                    // Either way it is unusable, so drop it and route around it
143                    // from the next send.
144                    self.workers.remove(idx);
145                    self.next = idx;
146                    Err(e)
147                }
148            };
149        }
150
151        Err(io::Error::new(
152            io::ErrorKind::NotConnected,
153            "PushFanOut has no live workers",
154        ))
155    }
156
157    /// Send one single-frame message to the next worker in round-robin order.
158    ///
159    /// Equivalent to `send(vec![frame])`, but avoids the per-message multipart
160    /// container allocation in single-frame hot paths.
161    pub async fn send_one(&mut self, frame: bytes::Bytes) -> io::Result<()> {
162        while !self.workers.is_empty() {
163            let idx = self.next % self.workers.len();
164            if !self.workers[idx].is_connected() {
165                self.workers.remove(idx);
166                self.next = idx;
167                continue;
168            }
169
170            return match self.workers[idx].send_one(frame).await {
171                Ok(()) => {
172                    self.next = idx + 1;
173                    Ok(())
174                }
175                Err(e) => {
176                    self.workers.remove(idx);
177                    self.next = idx;
178                    Err(e)
179                }
180            };
181        }
182
183        Err(io::Error::new(
184            io::ErrorKind::NotConnected,
185            "PushFanOut has no live workers",
186        ))
187    }
188
189    /// Flush every worker's write-coalescing buffer.
190    ///
191    /// Call this after the last `send` in a burst when the workers were created
192    /// with write coalescing enabled.
193    pub async fn flush(&mut self) -> io::Result<()> {
194        for worker in &mut self.workers {
195            worker.flush().await?;
196        }
197        Ok(())
198    }
199}