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}