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}