Skip to main content

ruststream_zeromq/
rpc.rs

1//! [`ZmqRpc`]: the DEALER/ROUTER pattern - request and reply.
2//!
3//! The responder side subscribes (a ROUTER socket): each request arrives with a `reply-to`
4//! header addressing the requesting peer, and the plain publisher routes replies back through
5//! the same ROUTER. The requester side uses the [`RequestReply`] capability: one DEALER per
6//! request, correlated by the `correlation-id` header.
7
8/// The publish policy of this form, under the name a mount site writes.
9///
10/// There is no separate request-side policy to name: both directions of the exchange run on one
11/// [`ZmqRpcPublisher`], which routes replies through the responder's ROUTER and issues requests
12/// over its own DEALER.
13pub use self::ZmqRpcPublish as Publish;
14
15/// The imports a routes file on the DEALER/ROUTER exchange writes, in one glob.
16///
17/// The framework's prelude, the shared [`ZmqEndpoint`], the descriptor [`ZmqRpc`], its publish
18/// policy as [`Publish`], and the [`RequestReply`] capability.
19///
20/// # Examples
21///
22/// ```
23/// use ruststream_zeromq::rpc::prelude::*;
24/// use serde::{Deserialize, Serialize};
25///
26/// #[derive(Deserialize)]
27/// struct Greeting {
28///     who: String,
29/// }
30///
31/// // An answer has no destination of its own: the ROUTER addresses it per request. The type
32/// // derives `Outgoing` without a name, and the clause's literal is the placeholder a publish
33/// // transform replaces with the request's `reply-to` address.
34/// #[derive(Serialize, Outgoing)]
35/// struct Answer {
36///     text: String,
37/// }
38///
39/// #[subscriber("greeter", reply("reply"))]
40/// async fn greet(request: &Greeting) -> Answer {
41///     Answer {
42///         text: format!("hello {}", request.who),
43///     }
44/// }
45///
46/// // Answers at the `reply-to` address each request carries, under its `correlation-id`; the
47/// // crate overview shows it in full.
48/// # use ruststream::runtime::{Outgoing, PublishContext};
49/// # struct ReplyToRequester;
50/// # impl<C, Options> PublishTransform<ForReply<C>, Options> for ReplyToRequester {
51/// #     type Destination = Names;
52/// #     fn apply(
53/// #         &self,
54/// #         out: &mut Outgoing<'_>,
55/// #         _options: &mut Option<Options>,
56/// #         cx: &PublishContext<'_, C>,
57/// #     ) {
58/// #         if let Some(reply_to) = cx.headers().get_shared("reply-to")
59/// #             && let Ok(reply_to) = Str::try_from(reply_to)
60/// #         {
61/// #             out.set_name(reply_to);
62/// #         }
63/// #         if let Some(correlation) = cx.headers().get_shared("correlation-id") {
64/// #             out.headers_mut().insert(Str::from_static("correlation-id"), correlation);
65/// #         }
66/// #     }
67/// # }
68///
69/// #[ruststream::app]
70/// fn app() -> impl App {
71///     RustStream::new(AppInfo::new("greeter", "0.1.0")).with_broker(
72///         ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557")),
73///         |b| {
74///             b.include(greet)
75///                 .out_reply(Publish)
76///                 .transform(ReplyToRequester)
77///                 .out_retry(Publish)
78///                 .to("greeter.retry");
79///         },
80///     )
81/// }
82/// ```
83pub mod prelude {
84    pub use ruststream::prelude::*;
85
86    pub use crate::endpoint::ZmqEndpoint;
87
88    // Only this form implements it; keep it out of the queue and fan-out preludes.
89    pub use ruststream::RequestReply;
90
91    // `Publish` is the mount-site vocabulary, and it is why this glob belongs in a routes file
92    // rather than a handler one: a handler imports the framework prelude alone and bounds its
93    // injected publisher with a broker capability trait, so the two names never meet.
94    pub use super::{Publish, ZmqRpc};
95}
96
97use std::fmt;
98use std::future::{Future, ready};
99use std::num::NonZeroUsize;
100use std::sync::Arc;
101use std::sync::atomic::Ordering;
102use std::time::Duration;
103
104use bytes::Bytes;
105use futures::Stream;
106use futures::lock::Mutex;
107#[cfg(feature = "asyncapi")]
108use ruststream::asyncapi::Bindings;
109use ruststream::{
110    Broker, BytesMut, ConnectedBroker, DefaultPublish, DescribeServer, NamedCopies,
111    OutgoingMessage, PairError, PublishPolicy, Publisher, RequestReply, ServerSpec, Str, Subscribe,
112    Subscriber, Take,
113};
114use tokio::sync::OnceCell;
115use zeromq::prelude::*;
116use zeromq::util::PeerIdentity;
117use zeromq::{DealerSocket, RouterSendHalf, RouterSocket, SocketOptions};
118
119#[cfg(feature = "asyncapi")]
120use crate::bindings::{self, SocketPair};
121#[cfg(feature = "testing")]
122use crate::common::DeliveryReceiver;
123use crate::common::{
124    DEFAULT_READ_AHEAD, DriverHandle, Lifecycle, SharedLifecycle, WireSubscriber, delivery_channel,
125    send_with_retry,
126};
127use crate::endpoint::{Endpoint, EndpointRole, ZmqEndpoint};
128use crate::error::ZmqError;
129#[cfg(feature = "testing")]
130use crate::in_process::{self, Bus};
131use crate::message::ZmqMessage;
132use crate::wire;
133
134/// The prefix of reply destinations minted by the responder subscription.
135pub(crate) const REPLY_PREFIX: &str = "zmq-reply:";
136
137/// The header a request carries the address of its answer in.
138pub(crate) const REPLY_TO_HEADER: &str = "reply-to";
139
140/// The header a request and its answer are matched on.
141pub(crate) const CORRELATION_ID_HEADER: &str = "correlation-id";
142
143/// Where a client reads that address, as the specification's runtime expression.
144///
145/// A reply on this pattern is addressed per request, so the generated document reports the reply
146/// channel without an address and points here instead.
147#[cfg(feature = "asyncapi")]
148pub(crate) const REPLY_ADDRESS_LOCATION: &str = "$message.header#/reply-to";
149
150/// Builds the reply destination addressing one requesting peer.
151///
152/// The responder's ROUTER derives `identity` from the peer that sent the request, over a socket and
153/// in process alike.
154pub(crate) fn reply_address(identity: &[u8]) -> String {
155    format!("{REPLY_PREFIX}{}", hex_encode(identity))
156}
157
158/// Mints the correlation id a request carries when the caller supplied none.
159pub(crate) fn new_correlation_id() -> String {
160    format!("req-{}-{}", std::process::id(), hex_encode(&rand_suffix()))
161}
162
163fn hex_encode(bytes: &[u8]) -> String {
164    use std::fmt::Write as _;
165    let mut out = String::with_capacity(bytes.len() * 2);
166    for b in bytes {
167        let _ = write!(out, "{b:02x}");
168    }
169    out
170}
171
172fn hex_decode(text: &str) -> Option<Vec<u8>> {
173    if !text.len().is_multiple_of(2) {
174        return None;
175    }
176    (0..text.len())
177        .step_by(2)
178        .map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok())
179        .collect()
180}
181
182/// The DEALER/ROUTER request-reply pattern.
183///
184/// The endpoint may take either side, and the pattern answers the same on both: a copy of a
185/// request has no address of its own, so every responder registration names where its retry
186/// copies go.
187///
188/// # Examples
189///
190/// A queue worker that asks a model service for a score before it finishes each job:
191///
192/// ```
193/// use std::error::Error;
194/// use std::time::Duration;
195///
196/// use ruststream::OutgoingMessage;
197/// use ruststream::codec::{Codec, JsonCodec};
198/// use ruststream_zeromq::prelude::*;
199/// use serde::{Deserialize, Serialize};
200///
201/// #[derive(Deserialize, Serialize)]
202/// struct Job {
203///     id: u64,
204/// }
205///
206/// #[derive(Deserialize)]
207/// struct Score {
208///     value: f64,
209/// }
210///
211/// #[subscriber("jobs")]
212/// async fn rate(
213///     job: &Job,
214///     Out(model): Out<impl RequestReply>,
215/// ) -> Result<(), Box<dyn Error + Send + Sync>> {
216///     let request = JsonCodec.encode(job)?;
217///     let reply = model
218///         .request(OutgoingMessage::new("score", request.as_ref()), Duration::from_secs(5))
219///         .await?;
220///     let score: Score = JsonCodec.decode(reply.payload())?;
221///     println!("job {} scored {}", job.id, score.value);
222///     Ok(())
223/// }
224///
225/// #[ruststream::app]
226/// fn app() -> impl App {
227///     let model = ZmqRpc::new(ZmqEndpoint::connect("tcp://model:5557")).bindable();
228///     let ask = model.bind(ZmqRpcPublish);
229///     RustStream::new(AppInfo::new("scorer", "0.1.0"))
230///         .with_broker(ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")), |b| {
231///             b.include(rate).out(DefaultSlot, ask).build();
232///         })
233///         .register_broker(model)
234/// }
235/// ```
236#[derive(Debug, Clone)]
237#[must_use]
238pub struct ZmqRpc {
239    endpoint: Endpoint,
240    read_ahead: NonZeroUsize,
241    cell: Arc<OnceCell<RpcShared>>,
242}
243
244#[derive(Clone)]
245pub(crate) struct RpcShared {
246    lifecycle: SharedLifecycle,
247    /// What replies route through; set when a subscription attaches.
248    router_tx: Arc<OnceCell<ReplyRoute>>,
249}
250
251/// What a reply routes through: the responder's ROUTER send half, or, in process, the transport
252/// that routes by the same identities.
253///
254/// Without the `testing` feature it is the send half itself: one variant, no tag, no branch.
255#[derive(Clone)]
256enum ReplyRoute {
257    /// The responder's ROUTER send half; empty between a responder closing and the next one
258    /// opening.
259    Router(Arc<Mutex<Option<RouterSendHalf>>>),
260    #[cfg(feature = "testing")]
261    InProcess(Arc<Bus>),
262}
263
264#[cfg(not(feature = "testing"))]
265const _: () = assert!(size_of::<ReplyRoute>() == size_of::<Arc<Mutex<Option<RouterSendHalf>>>>());
266
267impl fmt::Debug for RpcShared {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        f.debug_struct("RpcShared").finish_non_exhaustive()
270    }
271}
272
273impl ZmqRpc {
274    /// Records the endpoint, on either side. No I/O.
275    ///
276    /// # Examples
277    ///
278    /// A model service that answers each score request at the peer that sent it:
279    ///
280    /// ```
281    /// use ruststream_zeromq::rpc::prelude::*;
282    /// use serde::{Deserialize, Serialize};
283    ///
284    /// #[derive(Deserialize)]
285    /// struct Features {
286    ///     values: Vec<f64>,
287    /// }
288    ///
289    /// #[derive(Serialize, Outgoing)]
290    /// struct Score {
291    ///     value: f64,
292    /// }
293    ///
294    /// #[subscriber("score", reply("reply"))]
295    /// async fn score(request: &Features) -> Score {
296    ///     Score {
297    ///         value: request.values.iter().sum(),
298    ///     }
299    /// }
300    ///
301    /// // Answers at the request's `reply-to` address; the crate overview shows it in full.
302    /// # use ruststream::runtime::{Outgoing, PublishContext};
303    /// # struct ReplyToRequester;
304    /// # impl<C, Options> PublishTransform<ForReply<C>, Options> for ReplyToRequester {
305    /// #     type Destination = Names;
306    /// #     fn apply(
307    /// #         &self,
308    /// #         out: &mut Outgoing<'_>,
309    /// #         _options: &mut Option<Options>,
310    /// #         cx: &PublishContext<'_, C>,
311    /// #     ) {
312    /// #         if let Some(reply_to) = cx.headers().get_shared("reply-to")
313    /// #             && let Ok(reply_to) = Str::try_from(reply_to)
314    /// #         {
315    /// #             out.set_name(reply_to);
316    /// #         }
317    /// #         if let Some(correlation) = cx.headers().get_shared("correlation-id") {
318    /// #             out.headers_mut().insert(Str::from_static("correlation-id"), correlation);
319    /// #         }
320    /// #     }
321    /// # }
322    ///
323    /// #[ruststream::app]
324    /// fn app() -> impl App {
325    ///     RustStream::new(AppInfo::new("model", "0.1.0")).with_broker(
326    ///         ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557")),
327    ///         |b| {
328    ///             b.include(score)
329    ///                 .out_reply(Publish)
330    ///                 .transform(ReplyToRequester)
331    ///                 .out_retry(Publish)
332    ///                 .to("score.retry");
333    ///         },
334    ///     )
335    /// }
336    /// ```
337    pub fn new<Role: EndpointRole>(endpoint: ZmqEndpoint<Role>) -> Self {
338        Self {
339            endpoint: endpoint.into_inner(),
340            read_ahead: DEFAULT_READ_AHEAD,
341            cell: Arc::new(OnceCell::new()),
342        }
343    }
344
345    /// How many deliveries a subscription reads off its socket ahead of the handler; 1000 unless
346    /// set, the receive high-water mark `ZeroMQ` itself gives a socket.
347    ///
348    /// Past this bound the subscription stops reading, the socket's buffers fill, and the sender
349    /// waits: a handler slower than the wire slows the sender down rather than growing this
350    /// process's memory.
351    ///
352    /// # Examples
353    ///
354    /// A responder whose answers take a database round trip holds 64 requests ahead, and the
355    /// requesters wait beyond that:
356    ///
357    /// ```
358    /// use ruststream_zeromq::rpc::prelude::*;
359    /// use serde::{Deserialize, Serialize};
360    ///
361    /// #[derive(Deserialize)]
362    /// struct Lookup {
363    ///     customer: u64,
364    /// }
365    ///
366    /// #[derive(Serialize, Outgoing)]
367    /// struct Customer {
368    ///     name: String,
369    /// }
370    ///
371    /// #[subscriber("customers", reply("reply"))]
372    /// async fn lookup(request: &Lookup) -> Customer {
373    ///     Customer {
374    ///         name: format!("customer {}", request.customer),
375    ///     }
376    /// }
377    ///
378    /// // Answers at the request's `reply-to` address; the crate overview shows it in full.
379    /// # use ruststream::runtime::{Outgoing, PublishContext};
380    /// # struct ReplyToRequester;
381    /// # impl<C, Options> PublishTransform<ForReply<C>, Options> for ReplyToRequester {
382    /// #     type Destination = Names;
383    /// #     fn apply(
384    /// #         &self,
385    /// #         out: &mut Outgoing<'_>,
386    /// #         _options: &mut Option<Options>,
387    /// #         cx: &PublishContext<'_, C>,
388    /// #     ) {
389    /// #         if let Some(reply_to) = cx.headers().get_shared("reply-to")
390    /// #             && let Ok(reply_to) = Str::try_from(reply_to)
391    /// #         {
392    /// #             out.set_name(reply_to);
393    /// #         }
394    /// #         if let Some(correlation) = cx.headers().get_shared("correlation-id") {
395    /// #             out.headers_mut().insert(Str::from_static("correlation-id"), correlation);
396    /// #         }
397    /// #     }
398    /// # }
399    ///
400    /// #[ruststream::app]
401    /// fn app() -> impl App {
402    ///     let requests = ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557"))
403    ///         .read_ahead(nonzero!(64_usize));
404    ///     RustStream::new(AppInfo::new("customers", "0.1.0")).with_broker(requests, |b| {
405    ///         b.include(lookup)
406    ///             .out_reply(Publish)
407    ///             .transform(ReplyToRequester)
408    ///             .out_retry(Publish)
409    ///             .to("customers.retry");
410    ///     })
411    /// }
412    /// ```
413    pub const fn read_ahead(mut self, read_ahead: NonZeroUsize) -> Self {
414        self.read_ahead = read_ahead;
415        self
416    }
417
418    /// A publisher sharing this pattern's state; buildable before `connect`.
419    #[must_use]
420    pub fn publisher(&self) -> ZmqRpcPublisher {
421        ZmqRpcPublisher {
422            cell: Arc::clone(&self.cell),
423        }
424    }
425}
426
427impl Broker for ZmqRpc {
428    type Error = ZmqError;
429    type Connected = ConnectedZmqRpc;
430
431    async fn connect(self) -> Result<Self::Connected, Self::Error> {
432        self.connect_with(Lifecycle::new).await
433    }
434}
435
436impl ZmqRpc {
437    /// The connect transition over the transport `lifecycle` builds: the sockets, or the
438    /// in-process transport the test harness connects instead.
439    pub(crate) async fn connect_with(
440        self,
441        lifecycle: fn(Endpoint) -> Lifecycle,
442    ) -> Result<ConnectedZmqRpc, ZmqError> {
443        let shared = self
444            .cell
445            .get_or_try_init(async || {
446                self.endpoint.validate()?;
447                Ok::<_, ZmqError>(RpcShared {
448                    lifecycle: Arc::new(lifecycle(self.endpoint.clone())),
449                    router_tx: Arc::new(OnceCell::new()),
450                })
451            })
452            .await?
453            .clone();
454        Ok(ConnectedZmqRpc {
455            shared,
456            read_ahead: self.read_ahead,
457            cell: self.cell,
458        })
459    }
460}
461
462impl DescribeServer for ZmqRpc {
463    fn describe_server(&self) -> ServerSpec {
464        self.endpoint.server_spec()
465    }
466}
467
468/// The connected form of [`ZmqRpc`].
469#[derive(Debug)]
470pub struct ConnectedZmqRpc {
471    shared: RpcShared,
472    /// How far this subscription reads ahead, from the descriptor this form was connected from.
473    read_ahead: NonZeroUsize,
474    cell: Arc<OnceCell<RpcShared>>,
475}
476
477impl ConnectedZmqRpc {
478    #[cfg(feature = "testing")]
479    pub(crate) fn lifecycle(&self) -> &Lifecycle {
480        &self.shared.lifecycle
481    }
482
483    /// The address the responder resolved by binding (useful with an ephemeral
484    /// `tcp://...:0` endpoint); `None` until a subscription has bound.
485    #[must_use]
486    pub fn bound_address(&self) -> Option<String> {
487        self.shared.lifecycle.bound_address()
488    }
489
490    /// A publisher from the connected form.
491    #[must_use]
492    pub fn publisher(&self) -> ZmqRpcPublisher {
493        ZmqRpcPublisher {
494            cell: Arc::clone(&self.cell),
495        }
496    }
497}
498
499impl ConnectedBroker for ConnectedZmqRpc {
500    type Error = ZmqError;
501    type Closed = ();
502
503    fn shutdown(self) -> impl Future<Output = Result<(), Self::Error>> {
504        self.shared.lifecycle.closed.store(true, Ordering::Release);
505        ready(Ok(()))
506    }
507}
508
509/// A responder subscription on the ROUTER socket, yielding one request at a time.
510///
511/// It is the one subscriber of this crate that is no
512/// [`BatchSubscriber`](ruststream::BatchSubscriber), so `.batch(..)` on a `ZmqRpc` registration
513/// does not compile - deliberately. A responder exists to answer, and the address it answers at
514/// travels per request in the `reply-to` header the ROUTER stamps on it; a batch carries one
515/// publish context for the whole batch, so a batch's answers could not be addressed to the peers
516/// that asked. Keeping the capability off the type turns that into a compile error instead of a
517/// run of misrouted replies. Batch the one-way patterns ([`ZmqQueue`](crate::ZmqQueue),
518/// [`ZmqFanout`](crate::ZmqFanout)) instead, and answer requests one at a time.
519pub struct ZmqRpcSubscriber {
520    name: String,
521    inner: WireSubscriber,
522}
523
524impl fmt::Debug for ZmqRpcSubscriber {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        f.debug_struct("ZmqRpcSubscriber")
527            .field("name", &self.name)
528            .finish_non_exhaustive()
529    }
530}
531
532impl Subscriber for ZmqRpcSubscriber {
533    type Message = ZmqMessage;
534    type Error = ZmqError;
535
536    fn stream(&mut self) -> impl Stream<Item = Result<ZmqMessage, ZmqError>> + Send + '_ {
537        self.inner.stream()
538    }
539}
540
541impl Subscribe for ConnectedZmqRpc {
542    type Subscriber = ZmqRpcSubscriber;
543
544    /// A copy of a request has no address of its own, so the mount site names where one goes.
545    ///
546    /// The name a responder subscribes under is not a publish destination on this pattern:
547    /// [`ZmqRpcPublisher`] routes to the peer identity a request carried and refuses a plain
548    /// name, so a copy published under the subscription name would reach nothing. A registration
549    /// that binds `.out_retry(..)` over a responder therefore has to say where the copies go,
550    /// with `.to("name")` or with a transform that names one per delivery, and one that says
551    /// neither is refused before the subscription opens. Ask a requester again rather than
552    /// retrying its request from the responder side.
553    type Copies = NamedCopies;
554
555    async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
556        self.shared.lifecycle.ensure_open()?;
557        #[cfg(feature = "testing")]
558        if let Some(bus) = self.shared.lifecycle.in_process_bus() {
559            let slot = self.shared.lifecycle.attach_in_process(name).await?;
560            let (rx, registration) = bus.subscribe(name, read_request);
561            let _ = self
562                .shared
563                .router_tx
564                .set(ReplyRoute::InProcess(Arc::clone(bus)));
565            return Ok(ZmqRpcSubscriber {
566                name: name.to_owned(),
567                inner: WireSubscriber {
568                    rx: DeliveryReceiver::InProcess(rx),
569                    _driver: DriverHandle::InProcess(registration, slot),
570                },
571            });
572        }
573        let lifecycle = Arc::clone(&self.shared.lifecycle);
574        let subscription = name.to_owned();
575        let (socket, slot) = self
576            .shared
577            .lifecycle
578            .on_runtime(async move {
579                let mut socket = RouterSocket::new();
580                let slot = lifecycle
581                    .attach_receiver(&mut socket, &subscription)
582                    .await?;
583                Ok((socket, slot))
584            })
585            .await?;
586        let (send_half, mut recv_half) = socket.split();
587        // One responder ROUTER per pattern instance: replies route through it. A responder that
588        // reopens after the last one closed replaces the half, inside the lock a reply takes
589        // anyway.
590        let route = self
591            .shared
592            .router_tx
593            .get_or_init(async || ReplyRoute::Router(Arc::new(Mutex::new(None))))
594            .await;
595        match route {
596            ReplyRoute::Router(router) => *router.lock().await = Some(send_half),
597            // A socket subscription never opens on a broker connected in process.
598            #[cfg(feature = "testing")]
599            ReplyRoute::InProcess(_) => {}
600        }
601
602        let (tx, rx) = delivery_channel(self.read_ahead);
603        let task = self.shared.lifecycle.spawn(async move {
604            loop {
605                match recv_half.recv().await {
606                    Ok(message) => {
607                        let Some(item) = read_request(message) else {
608                            continue;
609                        };
610                        if tx.send(item).await.is_err() {
611                            break;
612                        }
613                    }
614                    Err(err) => {
615                        if tx
616                            .send(Err(ZmqError::Receive(err.to_string())))
617                            .await
618                            .is_err()
619                        {
620                            break;
621                        }
622                    }
623                }
624            }
625        });
626        Ok(ZmqRpcSubscriber {
627            name: name.to_owned(),
628            inner: WireSubscriber {
629                rx,
630                _driver: DriverHandle::Task(task, slot),
631            },
632        })
633    }
634}
635
636/// A request as the ROUTER's driver reads it: the identity frame the ROUTER put in front becomes
637/// the `reply-to` address the answer goes to, and the rest is the documented layout. A message
638/// with no identity frame is skipped.
639pub(crate) fn read_request(message: zeromq::ZmqMessage) -> Option<Result<ZmqMessage, ZmqError>> {
640    let mut frames = message.into_vecdeque();
641    let identity = frames.pop_front()?;
642    let rest: Result<zeromq::ZmqMessage, _> = frames.try_into();
643    let Ok(rest) = rest else {
644        return Some(Err(ZmqError::Wire(
645            "a request needs name and payload frames".into(),
646        )));
647    };
648    Some(wire::decode(rest).map(|(name, mut headers, payload)| {
649        headers.insert(Str::from_static(REPLY_TO_HEADER), reply_address(&identity));
650        ZmqMessage::new(name, headers, payload)
651    }))
652}
653
654/// Publishes replies back through the responder's ROUTER, and issues requests via
655/// [`RequestReply`].
656#[derive(Clone)]
657pub struct ZmqRpcPublisher {
658    cell: Arc<OnceCell<RpcShared>>,
659}
660
661impl fmt::Debug for ZmqRpcPublisher {
662    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        f.debug_struct("ZmqRpcPublisher").finish_non_exhaustive()
664    }
665}
666
667impl ZmqRpcPublisher {
668    fn shared(&self) -> Result<&RpcShared, ZmqError> {
669        let shared = self.cell.get().ok_or(ZmqError::NotConnected)?;
670        shared.lifecycle.ensure_open()?;
671        Ok(shared)
672    }
673}
674
675impl Publisher for ZmqRpcPublisher {
676    /// A frame owns its bytes: the payload becomes the message's third frame and the ROUTER
677    /// keeps it until the send completes.
678    type Payload = Take;
679
680    type Error = ZmqError;
681
682    /// ZMTP carries no per-message setting: a send takes the frames and nothing else, so there is
683    /// nothing for a call site to adjust. Which peer a reply reaches is a destination, not a
684    /// setting, and a naming publish transform supplies it. See the
685    /// [crate documentation](crate#per-message-settings).
686    type Options = ();
687
688    /// # Cancel safety
689    ///
690    /// Not cancel-safe. Dropping the future can leave the reply half-handed to the ROUTER, and the
691    /// requester then waits out its timeout. Answer from a task of its own rather than inside a
692    /// `select!` arm.
693    async fn publish(
694        &self,
695        msg: OutgoingMessage<'_, BytesMut>,
696        _options: Option<&Self::Options>,
697    ) -> Result<(), Self::Error> {
698        let shared = self.shared()?;
699        let Some(identity_hex) = msg.name().strip_prefix(REPLY_PREFIX) else {
700            return Err(ZmqError::Send {
701                name: msg.name().to_owned(),
702                reason: format!(
703                    "the rpc publisher routes '{REPLY_PREFIX}...' replies; use request() for outbound requests"
704                ),
705            });
706        };
707        let identity = hex_decode(identity_hex).ok_or_else(|| ZmqError::Send {
708            name: msg.name().to_owned(),
709            reason: "malformed reply address".to_owned(),
710        })?;
711        let router = shared.router_tx.get().ok_or_else(|| ZmqError::Send {
712            name: msg.name().to_owned(),
713            reason: "no responder subscription is attached".to_owned(),
714        })?;
715
716        // Frame 0 of a reply is the literal "reply". The ROUTER identity frame pushed in front of
717        // it is what addresses the requester, so the name position carries nothing to route on.
718        let (name, payload, headers) = msg.into_parts();
719        let message = wire::encode_addressed_to(
720            name,
721            Bytes::from(identity),
722            "reply",
723            &headers,
724            payload.freeze(),
725        )?;
726        match router {
727            ReplyRoute::Router(router) => {
728                let mut guard = router.lock().await;
729                let Some(half) = guard.as_mut() else {
730                    return Err(ZmqError::Send {
731                        name: name.to_owned(),
732                        reason: "no responder subscription is attached".to_owned(),
733                    });
734                };
735                let sent = half.send(message).await;
736                drop(guard);
737                sent.map_err(|e| ZmqError::Send {
738                    name: name.to_owned(),
739                    reason: e.to_string(),
740                })
741            }
742            #[cfg(feature = "testing")]
743            ReplyRoute::InProcess(bus) => bus.reply(name, message),
744        }
745    }
746}
747
748impl RequestReply for ZmqRpcPublisher {
749    type Reply = ZmqMessage;
750
751    /// # Cancel safety
752    ///
753    /// Not cancel-safe. Dropping the future closes the DEALER this request was issued on, so an
754    /// answer already in flight is lost, and the request itself may have reached the responder
755    /// already. Give up through `timeout` rather than by cancelling.
756    async fn request(
757        &self,
758        msg: OutgoingMessage<'_, BytesMut>,
759        timeout: Duration,
760    ) -> Result<Self::Reply, Self::Error> {
761        let shared = self.shared()?;
762        #[cfg(feature = "testing")]
763        if let Some(bus) = shared.lifecycle.in_process_bus() {
764            return request_in_process(bus, &shared.lifecycle, msg, timeout).await;
765        }
766        let address = shared.lifecycle.send_target().address().to_owned();
767
768        // One DEALER per request: simple and correct; a shared correlated link is a later
769        // optimisation. The identity is random, so replies route to this request alone.
770        let mut options = SocketOptions::default();
771        options.peer_identity(PeerIdentity::new());
772        options.connect_timeout(timeout.min(Duration::from_secs(10)));
773        let mut dealer = DealerSocket::with_options(options);
774        dealer
775            .connect(&address)
776            .await
777            .map_err(|e| ZmqError::Endpoint {
778                endpoint: address.clone(),
779                source: crate::error::box_err(e),
780            })?;
781
782        // Respect a caller-supplied correlation id (an upper layer may match on it too).
783        let correlation = msg
784            .headers()
785            .correlation_id()
786            .map_or_else(new_correlation_id, str::to_owned);
787        let (name, payload, mut headers) = msg.into_parts();
788        headers.insert(Str::from_static(CORRELATION_ID_HEADER), correlation.clone());
789        let request = wire::encode_to(name, name, &headers, payload.freeze())?;
790        send_with_retry(&mut dealer, name, request).await?;
791
792        let exchange = async {
793            loop {
794                let reply = dealer
795                    .recv()
796                    .await
797                    .map_err(|e| ZmqError::Receive(e.to_string()))?;
798                let (name, reply_headers, payload) = wire::decode(reply)?;
799                if reply_headers.correlation_id() == Some(correlation.as_str()) {
800                    return Ok(ZmqMessage::new(name, reply_headers, payload));
801                }
802            }
803        };
804        tokio::time::timeout(timeout, exchange)
805            .await
806            .unwrap_or(Err(ZmqError::RequestTimeout))
807    }
808}
809
810/// A request over the in-process transport, framed and correlated the way the DEALER does it: it
811/// reaches the responder this service binds, when the DEALER would dial one, and a peer outside
812/// this service otherwise.
813#[cfg(feature = "testing")]
814async fn request_in_process(
815    bus: &Bus,
816    lifecycle: &Lifecycle,
817    msg: OutgoingMessage<'_, BytesMut>,
818    timeout: Duration,
819) -> Result<ZmqMessage, ZmqError> {
820    let correlation = msg
821        .headers()
822        .correlation_id()
823        .map_or_else(new_correlation_id, str::to_owned);
824    let (name, payload, mut headers) = msg.into_parts();
825    headers.insert(Str::from_static(CORRELATION_ID_HEADER), correlation.clone());
826    let request = wire::encode_to(name, name, &headers, payload.freeze())?;
827    let local = lifecycle.local_listener().is_some();
828    in_process::request(bus, local, name, request, &correlation, timeout).await
829}
830
831/// A peer identity no other peer of this process carries, standing in for the random identity a
832/// DEALER greets with.
833#[cfg(feature = "testing")]
834pub(crate) fn peer_identity() -> Bytes {
835    Bytes::copy_from_slice(&rand_suffix())
836}
837
838/// A per-request unique suffix without a randomness dependency: the address of a fresh
839/// allocation mixed with a monotonic counter.
840fn rand_suffix() -> [u8; 8] {
841    use std::sync::atomic::AtomicU64;
842    static SEQ: AtomicU64 = AtomicU64::new(0);
843    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
844    seq.to_be_bytes()
845}
846
847/// The publish policy for [`ZmqRpcPublisher`].
848///
849/// # Examples
850///
851/// A service that does not start until the model it depends on answers a request:
852///
853/// ```
854/// use std::io;
855/// use std::time::Duration;
856///
857/// use ruststream::OutgoingMessage;
858/// use ruststream_zeromq::prelude::*;
859///
860/// #[ruststream::app]
861/// fn app() -> impl App {
862///     RustStream::new(AppInfo::new("scorer", "0.1.0")).with_broker(
863///         ZmqRpc::new(ZmqEndpoint::connect("tcp://model:5557")),
864///         |b| {
865///             b.after_startup(ZmqRpcPublish, async move |model| -> io::Result<()> {
866///                 model
867///                     .request(
868///                         OutgoingMessage::new("health", b"ping".as_slice()),
869///                         Duration::from_secs(5),
870///                     )
871///                     .await
872///                     .map_err(io::Error::other)?;
873///                 Ok(())
874///             });
875///         },
876///     )
877/// }
878/// ```
879#[derive(Debug, Clone, Copy, Default)]
880#[must_use]
881pub struct ZmqRpcPublish;
882
883impl PublishPolicy<ConnectedZmqRpc> for ZmqRpcPublish {
884    type Live = ZmqRpcPublisher;
885
886    fn pair(
887        self,
888        connected: &ConnectedZmqRpc,
889    ) -> impl Future<Output = Result<Self::Live, PairError>> {
890        ready(Ok(connected.publisher()))
891    }
892
893    /// The destination reaches the binding and stays out of it: a reply travels to the identity
894    /// the ROUTER supplies, so no name on this channel is an address a peer can send to.
895    #[cfg(feature = "asyncapi")]
896    fn channel_bindings(&self, channel: &str) -> Bindings {
897        bindings::channel(SocketPair::DealerRouter, channel)
898    }
899
900    #[cfg(feature = "asyncapi")]
901    fn reply_address_location(&self) -> Option<&'static str> {
902        Some(REPLY_ADDRESS_LOCATION)
903    }
904}
905
906impl DefaultPublish for ConnectedZmqRpc {
907    type Policy = ZmqRpcPublish;
908}
909
910#[cfg(test)]
911mod tests {
912    //! Where the batching capability sits, pinned as bounds.
913    //!
914    //! The one-way patterns batch; the responder only delivers, so a `.batch(..)` registration on
915    //! [`ZmqRpc`] fails to compile rather than answering a batch of requests at one address. The
916    //! absence cannot be written as a bound, so it lives in [`ZmqRpcSubscriber`]'s own docs; what
917    //! is checkable is that the split has not quietly collapsed.
918
919    use ruststream::{BatchSubscriber, Subscriber};
920
921    use super::ZmqRpcSubscriber;
922    use crate::ZmqSubscriber;
923
924    fn batches<S: BatchSubscriber>() {}
925
926    fn delivers<S: Subscriber>() {}
927
928    // The instantiations are the assertion, so the body has nothing to run; a test rather than an
929    // uncalled function because a chain of uncalled helpers is dead code on one compiler and live
930    // on another, and the floor toolchain disagrees with stable about which.
931    #[test]
932    fn the_one_way_patterns_batch_and_the_responder_only_delivers() {
933        batches::<ZmqSubscriber>();
934        delivers::<ZmqRpcSubscriber>();
935    }
936}