Skip to main content

ruststream_zeromq/
rpc.rs

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