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}