ruststream_sea_file/stdio.rs
1//! Standard input and output as one stream: a service that is a stage of a shell pipeline.
2//!
3//! [`StdioBroker`] records nothing and attaches the process's own pipes on
4//! [`Broker::connect`]. Standard input is the subscription, standard output the publisher, so
5//! `producer | service | consumer` works with ordinary command-line tools. A service on a
6//! pipeline globs this form's [`prelude`] and names its policy [`Publish`].
7//!
8//! - [StdioBroker][stdio-broker]: [`StdioBroker`], and [`loopback`](StdioBroker::loopback) for a
9//! stage that reads its own output ([loopback][loopback]).
10//! - [Lines][lines]: the client's `[timestamp | stream_key | sequence | shard_id] payload` line
11//! format, read by a subscription on its stream key through [`StdioSubscriber`].
12//! - [StdioPublish][stdio-publish]: [`Publish`] and its live [`StdioPublisher`], one line per
13//! message on standard output.
14//! - [Payloads][payloads]: binary, multi-line and padded payloads through the envelope.
15//! - [out_retry][out-retry]: where a deferred copy goes, named at the mount site.
16//! - [Shutdown][shutdown]: a stdio shutdown ends every stdio stream of the process.
17//!
18//! [stdio-broker]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/stdio-broker/
19//! [loopback]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/loopback/
20//! [lines]: https://powersemmi.github.io/ruststream-sea-file/latest/components/subscriptions/lines/
21//! [stdio-publish]: https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/stdio-publish/
22//! [payloads]: https://powersemmi.github.io/ruststream-sea-file/latest/components/headers/payloads/
23//! [out-retry]: https://powersemmi.github.io/ruststream-sea-file/latest/components/settlement/out-retry/
24//! [shutdown]: https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/shutdown/
25
26// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
27// matches stay so that the in-process arm has its place when the feature is on.
28#![cfg_attr(
29 not(feature = "testing"),
30 allow(clippy::infallible_destructuring_match)
31)]
32
33use std::future::{Future, ready};
34use std::io;
35use std::num::NonZeroUsize;
36use std::pin::pin;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39
40use futures::{FutureExt as _, Stream};
41#[cfg(feature = "testing")]
42use ruststream::RawMessage;
43#[cfg(feature = "testing")]
44use ruststream::testing::{Coordinator, InProcess, TestableBroker};
45use ruststream::{
46 BatchSubscriber, Broker, BufferedSubscriber, ConnectedBroker, DefaultPublish, DescribeServer,
47 Lend, NamedCopies, OutgoingMessage, PairError, PublishPolicy, Publisher, ServerSpec, Subscribe,
48 Subscriber,
49};
50use sea_streamer_stdio::{
51 StdioConnectOptions, StdioConsumer, StdioConsumerOptions, StdioProducer, StdioProducerOptions,
52 StdioResult, StdioStreamer,
53};
54use sea_streamer_types::{
55 Consumer as _, ConsumerMode, ConsumerOptions as _, Producer as _, StreamKey, Streamer as _,
56 StreamerUri,
57};
58use tokio::runtime::Handle;
59use tokio::sync::{OnceCell, mpsc};
60
61use crate::batching::BATCH_MAX_WAIT;
62use crate::error::{SeaFileError, box_err};
63#[cfg(feature = "testing")]
64use crate::in_process::{MemoryPipe, PipeQueue};
65use crate::message::SeaMessage;
66use crate::wire;
67
68/// Whether a subscription in this process has started the client's standard-input reader.
69///
70/// Why a static: the reader is the client's, and it is process-wide. The first consumer starts
71/// it, and it then holds standard input while it waits for a line, so only the subscription that
72/// starts it can hold standard input itself (see `open_consumer`); a later one would wait for
73/// the next line.
74static READER_STARTED: AtomicBool = AtomicBool::new(false);
75
76pub(crate) struct StdioCore {
77 pub(crate) link: StdioLink,
78 pub(crate) closed: AtomicBool,
79 /// The broker's own setting, which the test harness reads to know where a publish goes. The
80 /// field is there only with the `testing` feature.
81 #[cfg(feature = "testing")]
82 loopback: bool,
83}
84
85/// What a connected broker and every handle paired off it speak over: the process's own pipes,
86/// or, under the `testing` feature, the in-memory ones the test harness connected instead.
87///
88/// Without the feature there is one variant, so the type is the client's streamer itself and every
89/// `match` on it is irrefutable: a production build carries no second transport and no branch to
90/// it.
91pub(crate) enum StdioLink {
92 Stdio(Pipes),
93 #[cfg(feature = "testing")]
94 InProcess(Arc<MemoryPipe>),
95}
96
97/// The process's own pipes as the client opened them, and the runtime `connect` ran on: the
98/// reader task of every subscription runs there, whichever runtime opens it.
99pub(crate) struct Pipes {
100 streamer: StdioStreamer,
101 runtime: Handle,
102}
103
104// The zero-cost promise of the in-process mode, held by the compiler: a build without it gives the
105// link exactly the size of the pipes it wraps.
106#[cfg(not(feature = "testing"))]
107const _: () = assert!(size_of::<StdioLink>() == size_of::<Pipes>());
108
109impl StdioCore {
110 fn ensure_open(&self) -> Result<(), SeaFileError> {
111 if self.closed.load(Ordering::Acquire) {
112 return Err(SeaFileError::NotConnected);
113 }
114 Ok(())
115 }
116}
117
118impl std::fmt::Debug for StdioCore {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.debug_struct("StdioCore")
121 .field("closed", &self.closed.load(Ordering::Relaxed))
122 .finish_non_exhaustive()
123 }
124}
125
126type StdioCell = Arc<OnceCell<Arc<StdioCore>>>;
127
128/// Standard input and output as one stream: consume lines from stdin, publish lines to
129/// stdout, in the client's `[timestamp | stream_key | seq] payload` line format.
130///
131/// Shutting this broker down ends every stdio subscription and publisher in the process, after
132/// the lines each subscription has already read: the client's transport is process-wide. The
133/// [StdioBroker](https://powersemmi.github.io/ruststream-sea-file/latest/components/broker/stdio-broker/)
134/// page shows it in a service.
135///
136/// # Examples
137///
138/// ```
139/// use ruststream_sea_file::stdio::prelude::*;
140/// use serde::{Deserialize, Serialize};
141///
142/// #[derive(Debug, Deserialize)]
143/// struct Line {
144/// text: String,
145/// }
146///
147/// #[derive(Debug, Outgoing, Serialize)]
148/// #[outgoing(name = "upper")]
149/// struct Upper {
150/// text: String,
151/// }
152///
153/// #[subscriber("lines", reply)]
154/// async fn shout(line: &Line) -> Upper {
155/// Upper { text: line.text.to_uppercase() }
156/// }
157///
158/// #[ruststream::app]
159/// fn app() -> impl App {
160/// RustStream::new(AppInfo::new("shout", "0.1.0")).with_broker(StdioBroker::new(), |b| {
161/// b.include(shout).out_retry(Publish).to("lines.retry");
162/// })
163/// }
164/// ```
165#[derive(Debug, Clone, Default)]
166#[must_use]
167pub struct StdioBroker {
168 loopback: bool,
169 cell: StdioCell,
170}
171
172impl StdioBroker {
173 /// Records configuration only. No I/O.
174 pub fn new() -> Self {
175 Self::default()
176 }
177
178 /// Loops published messages back to this process's own subscribers (for tests).
179 pub fn loopback(mut self) -> Self {
180 self.loopback = true;
181 self
182 }
183
184 /// A publisher sharing this broker's connection cell; buildable before `connect`.
185 #[must_use]
186 pub fn publisher(&self) -> StdioPublisher {
187 StdioPublisher {
188 cell: Arc::clone(&self.cell),
189 producer: Arc::new(OnceCell::new()),
190 }
191 }
192}
193
194impl Broker for StdioBroker {
195 type Error = SeaFileError;
196 type Connected = ConnectedStdioBroker;
197
198 async fn connect(self) -> Result<Self::Connected, Self::Error> {
199 let core = self
200 .cell
201 .get_or_try_init(async || {
202 let mut options = StdioConnectOptions::default();
203 options.set_loopback(self.loopback);
204 let streamer = StdioStreamer::connect(StreamerUri::zero(), options)
205 .await
206 .map_err(|e| SeaFileError::Connect {
207 target: "stdio".to_owned(),
208 source: box_err(e),
209 })?;
210 Ok::<_, SeaFileError>(Arc::new(StdioCore {
211 link: StdioLink::Stdio(Pipes {
212 streamer,
213 runtime: Handle::current(),
214 }),
215 closed: AtomicBool::new(false),
216 #[cfg(feature = "testing")]
217 loopback: self.loopback,
218 }))
219 })
220 .await?
221 .clone();
222 Ok(ConnectedStdioBroker {
223 core,
224 cell: self.cell,
225 })
226 }
227}
228
229/// The in-process mode: the connected form a test runs the production app against, over a pipe
230/// held in memory in place of the process's own standard input and output.
231///
232/// The pipe belongs to this broker and every clone of it. What the test injects arrives on its
233/// standard input; what the service publishes is written to its standard output, and reaches the
234/// service's own subscriptions only under [`loopback`](Self::loopback), as on a real pipe.
235#[cfg(feature = "testing")]
236impl InProcess for StdioBroker {
237 async fn connect_in_process(self) -> Result<Self::Connected, Self::Error> {
238 let core = self
239 .cell
240 .get_or_init(async || {
241 Arc::new(StdioCore {
242 link: StdioLink::InProcess(MemoryPipe::new(self.loopback)),
243 closed: AtomicBool::new(false),
244 loopback: self.loopback,
245 })
246 })
247 .await
248 .clone();
249 Ok(ConnectedStdioBroker {
250 core,
251 cell: self.cell,
252 })
253 }
254}
255
256#[cfg(feature = "testing")]
257ruststream::register_testable_broker!(StdioBroker);
258
259/// Opens a consumer of `key` on standard input.
260///
261/// The client starts its reader inside the first consumer and only then registers that consumer,
262/// so a line already waiting on the pipe could be read in between and handed to no one: a stage
263/// started with its input ready lost its first line. The first subscription therefore holds
264/// standard input while the consumer is created, and the reader takes its first line only once
265/// the consumer is there. Creating a consumer completes without waiting, so standard input is
266/// held for that one poll and never across an await.
267async fn open_consumer(streamer: &StdioStreamer, key: StreamKey) -> StdioResult<StdioConsumer> {
268 let keys = [key];
269 let mut created =
270 pin!(streamer.create_consumer(&keys, StdioConsumerOptions::new(ConsumerMode::RealTime)));
271 if READER_STARTED.swap(true, Ordering::AcqRel) {
272 return created.await;
273 }
274 let first = {
275 let _input = io::stdin().lock();
276 created.as_mut().now_or_never()
277 };
278 match first {
279 Some(consumer) => consumer,
280 None => created.await,
281 }
282}
283
284impl DescribeServer for StdioBroker {
285 fn describe_server(&self) -> ServerSpec {
286 ServerSpec::in_process("stdio")
287 }
288}
289
290/// The typed witness that `connect` succeeded.
291#[derive(Debug)]
292pub struct ConnectedStdioBroker {
293 core: Arc<StdioCore>,
294 // Keeps the cell of publishers handed out before connect alive and filled.
295 cell: StdioCell,
296}
297
298impl ConnectedStdioBroker {
299 /// A publisher from the connected form.
300 #[must_use]
301 pub fn publisher(&self) -> StdioPublisher {
302 StdioPublisher {
303 cell: Arc::clone(&self.cell),
304 producer: Arc::new(OnceCell::new()),
305 }
306 }
307}
308
309impl ConnectedBroker for ConnectedStdioBroker {
310 type Error = SeaFileError;
311 type Closed = ();
312
313 async fn shutdown(self) -> Result<(), Self::Error> {
314 self.core.closed.store(true, Ordering::Release);
315 let streamer = match &self.core.link {
316 StdioLink::Stdio(pipes) => &pipes.streamer,
317 // The in-memory pipe belongs to this broker alone, so its shutdown ends only the
318 // subscriptions on it.
319 #[cfg(feature = "testing")]
320 StdioLink::InProcess(pipe) => {
321 pipe.finish();
322 return Ok(());
323 }
324 };
325 // Globally destructive by the client's design: every stdio consumer and producer in
326 // the process ends. That is the honest meaning of shutting down a process-wide
327 // transport.
328 streamer
329 .clone()
330 .disconnect()
331 .await
332 .map_err(|e| SeaFileError::Connect {
333 target: "stdio".to_owned(),
334 source: box_err(e),
335 })
336 }
337}
338
339impl Subscribe for ConnectedStdioBroker {
340 type Subscriber = StdioSubscriber;
341 /// The mount site names where a deferred copy goes, because nothing on this transport
342 /// addresses the subscription: a publish writes to standard output and a subscription reads
343 /// standard input, so the process downstream of the pipe is not the one that sent the
344 /// message.
345 ///
346 /// A registration over stdio therefore names a destination - `.out_retry(Publish).to(name)`,
347 /// or a transform that names one per delivery - and one that names neither refuses to start,
348 /// which is the right answer for a pipeline stage: a delayed message that went nowhere is
349 /// worse than a service that does not come up. ([`loopback`](StdioBroker::loopback) does
350 /// route a publish back to this process, but it is a test aid, and an address that only held
351 /// under it would break in the shape a service ships.)
352 type Copies = NamedCopies;
353
354 async fn subscribe(&self, name: &str) -> Result<Self::Subscriber, Self::Error> {
355 self.core.ensure_open()?;
356 let key =
357 StreamKey::new(name).map_err(|e| SeaFileError::Invalid(format!("'{name}': {e}")))?;
358 let pipes = match &self.core.link {
359 StdioLink::Stdio(pipes) => pipes,
360 #[cfg(feature = "testing")]
361 StdioLink::InProcess(pipe) => {
362 return Ok(StdioSubscriber {
363 stream: name.to_owned(),
364 inner: BufferedSubscriber::new(StdioDeliveries::InProcess(
365 pipe.subscribe(key.name()),
366 ))
367 .max_wait(BATCH_MAX_WAIT),
368 });
369 }
370 };
371 let consumer =
372 open_consumer(&pipes.streamer, key)
373 .await
374 .map_err(|e| SeaFileError::Subscribe {
375 stream: name.to_owned(),
376 source: box_err(e),
377 })?;
378
379 let (tx, rx) = mpsc::channel(64);
380 pipes.runtime.spawn(async move {
381 loop {
382 tokio::select! {
383 () = tx.closed() => break,
384 next = consumer.next() => match next {
385 Ok(message) => {
386 if tx.send(Ok(SeaMessage::new(&message))).await.is_err() {
387 break;
388 }
389 }
390 // The client fails a read only once its consumers are disconnected,
391 // which is what the shutdown does: the subscription ends there.
392 Err(_) => break,
393 },
394 }
395 }
396 });
397 Ok(StdioSubscriber {
398 stream: name.to_owned(),
399 inner: BufferedSubscriber::new(StdioDeliveries::Reader(rx)).max_wait(BATCH_MAX_WAIT),
400 })
401 }
402}
403
404impl DefaultPublish for ConnectedStdioBroker {
405 type Policy = StdioPublish;
406}
407
408/// The harness's view of the in-process transport: what it feeds to standard input, what it
409/// reads back, and the coordinator it counts the in-flight deliveries with.
410///
411/// An injected message is a line arriving on standard input, so every subscription on its stream
412/// key reads it. A publish is a line on standard output, which reaches the process downstream:
413/// [`routes`](TestableBroker::routes) answers that it reaches this service's subscriptions of the
414/// same stream key under [`loopback`](StdioBroker::loopback), and none of them otherwise.
415///
416/// # Panics
417///
418/// `inject` and `published` panic on a broker connected with `connect`: the harness drives only
419/// the pipe `connect_in_process` produces. `inject` also panics on a line a pipe cannot carry: an
420/// invalid stream key, or a message with no payload and no headers.
421#[cfg(feature = "testing")]
422impl TestableBroker for ConnectedStdioBroker {
423 fn install_coordinator(&self, coordinator: Coordinator) {
424 if let StdioLink::InProcess(pipe) = &self.core.link {
425 pipe.install(coordinator);
426 }
427 }
428
429 fn inject(&self, message: OutgoingMessage<'_>) {
430 let pipe = self.memory_pipe("inject");
431 let key = StreamKey::new(message.name()).unwrap_or_else(|err| {
432 panic!(
433 "the injected message to {:?} is not one a pipe carries: {err}",
434 message.name()
435 )
436 });
437 assert!(
438 !(message.payload().is_empty() && message.headers().is_empty()),
439 "the injected message to {:?} is empty, and a pipe drops an empty line",
440 message.name(),
441 );
442 // The line an upstream stage of the pipeline writes for this message.
443 pipe.feed(
444 key,
445 wire::encode(message.headers(), message.payload(), true),
446 );
447 }
448
449 fn published(&self, name: &str) -> Vec<RawMessage> {
450 self.memory_pipe("published").published(name)
451 }
452
453 fn routes(&self, destination: &str, subscriptions: &[&str]) -> Vec<usize> {
454 if !self.core.loopback {
455 return Vec::new();
456 }
457 subscriptions
458 .iter()
459 .enumerate()
460 .filter(|(_, name)| **name == destination)
461 .map(|(position, _)| position)
462 .collect()
463 }
464}
465
466#[cfg(feature = "testing")]
467impl ConnectedStdioBroker {
468 /// The in-memory pipe, which is all the harness drives.
469 fn memory_pipe(&self, what: &str) -> &MemoryPipe {
470 match &self.core.link {
471 StdioLink::InProcess(pipe) => pipe,
472 StdioLink::Stdio(_) => panic!(
473 "TestableBroker::{what} reached a broker connected with `connect`; the harness \
474 drives the pipe `connect_in_process` produces"
475 ),
476 }
477 }
478}
479
480/// A subscription to one stream key on standard input; yields [`SeaMessage`]s.
481///
482/// Standard input has no retained log: there is no acknowledgement and no repositioning, and
483/// both are reported as unsupported rather than pretended. Batches it does serve: the client
484/// reads one line at a time, so they are assembled here, capped at the size the mount site
485/// named.
486pub struct StdioSubscriber {
487 stream: String,
488 inner: BufferedSubscriber<StdioDeliveries>,
489}
490
491impl std::fmt::Debug for StdioSubscriber {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 f.debug_struct("StdioSubscriber")
494 .field("stream", &self.stream)
495 .finish_non_exhaustive()
496 }
497}
498
499impl Subscriber for StdioSubscriber {
500 type Message = SeaMessage;
501 type Error = SeaFileError;
502
503 fn stream(&mut self) -> impl Stream<Item = Result<SeaMessage, SeaFileError>> + Send + '_ {
504 self.inner.stream()
505 }
506}
507
508impl BatchSubscriber for StdioSubscriber {
509 type Batch = Vec<SeaMessage>;
510
511 fn batches(
512 &mut self,
513 size: NonZeroUsize,
514 ) -> impl Stream<Item = Result<Self::Batch, SeaFileError>> + Send + '_ {
515 self.inner.batches(size)
516 }
517}
518
519/// A subscription's lines, before batching: one line per poll, in arrival order.
520///
521/// Without the `testing` feature there is one variant, so the type is the reader task's channel
522/// itself and every `match` on it is irrefutable.
523enum StdioDeliveries {
524 /// The reader task's channel, fed from standard input.
525 Reader(mpsc::Receiver<Result<SeaMessage, SeaFileError>>),
526 /// The in-process pipe's queue.
527 #[cfg(feature = "testing")]
528 InProcess(PipeQueue),
529}
530
531// The zero-cost promise of the in-process mode, held by the compiler.
532#[cfg(not(feature = "testing"))]
533const _: () = assert!(
534 size_of::<StdioDeliveries>() == size_of::<mpsc::Receiver<Result<SeaMessage, SeaFileError>>>()
535);
536
537impl Subscriber for StdioDeliveries {
538 type Message = SeaMessage;
539 type Error = SeaFileError;
540
541 fn stream(&mut self) -> impl Stream<Item = Result<SeaMessage, SeaFileError>> + Send + '_ {
542 // Poll the channel in place rather than wrapping it in an owning stream, so `stream`
543 // can be called again after the returned stream is dropped (the runtime and the
544 // conformance helpers re-enter it per call).
545 futures::stream::poll_fn(move |cx| match self {
546 Self::Reader(rx) => rx.poll_recv(cx),
547 #[cfg(feature = "testing")]
548 Self::InProcess(queue) => queue.poll_next(cx).map(|next| next.map(Ok)),
549 })
550 }
551}
552
553/// Publishes messages to standard output.
554///
555/// The line format is the client's own. A line is text, ends at a newline, and the reader trims
556/// it, so a payload that is not UTF-8, holds a newline or begins or ends with whitespace (and any
557/// message with headers) travels in the text-safe envelope. The client silently drops
558/// empty lines, so an empty payload is rejected here instead.
559///
560/// A publish carries no per-message settings: a line on standard output takes a key and a
561/// payload and nothing else, so [`Publisher::Options`] is the unit type and the publish builder
562/// gains no step from this crate.
563///
564/// Standard output reaches the next process in the pipeline and never this process's own
565/// standard input, so a registration that defers a delivery names where the copy goes, with
566/// `.out_retry(Publish).to(key)` or a transform that names one per delivery; one that names
567/// neither refuses to start. The
568/// [StdioPublish](https://powersemmi.github.io/ruststream-sea-file/latest/components/publishing/stdio-publish/)
569/// page shows it in a service.
570///
571/// # Examples
572///
573/// ```
574/// use ruststream_sea_file::stdio::prelude::*;
575/// use serde::{Deserialize, Serialize};
576///
577/// #[derive(Debug, Deserialize)]
578/// struct Job {
579/// id: u64,
580/// }
581///
582/// #[derive(Debug, Outgoing, Serialize)]
583/// #[outgoing(name = "results")]
584/// struct Done {
585/// id: u64,
586/// }
587///
588/// #[subscriber("jobs", reply)]
589/// async fn work(job: &Job) -> Done {
590/// tracing::info!(job.id, "done");
591/// Done { id: job.id }
592/// }
593///
594/// #[ruststream::app]
595/// fn app() -> impl App {
596/// RustStream::new(AppInfo::new("pipeline", "0.1.0")).with_broker(StdioBroker::new(), |b| {
597/// b.include(work).out_retry(Publish).to("jobs.retry");
598/// })
599/// }
600/// ```
601#[derive(Clone)]
602pub struct StdioPublisher {
603 cell: StdioCell,
604 producer: Arc<OnceCell<StdioProducer>>,
605}
606
607impl std::fmt::Debug for StdioPublisher {
608 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609 f.debug_struct("StdioPublisher").finish_non_exhaustive()
610 }
611}
612
613impl Publisher for StdioPublisher {
614 /// A line on standard output is written from the bytes and keeps nothing: the client's
615 /// `send_to` takes a slice, and the text-safe envelope is a buffer of this crate's own.
616 type Payload = Lend;
617
618 type Error = SeaFileError;
619 type Options = ();
620
621 async fn publish(
622 &self,
623 msg: OutgoingMessage<'_, &[u8]>,
624 _options: Option<&()>,
625 ) -> Result<(), Self::Error> {
626 let core = self.cell.get().ok_or(SeaFileError::NotConnected)?;
627 core.ensure_open()?;
628 if msg.payload().is_empty() && msg.headers().is_empty() {
629 return Err(SeaFileError::Invalid(
630 "stdio drops empty lines; an empty message cannot be transmitted".into(),
631 ));
632 }
633 let streamer = match &core.link {
634 StdioLink::Stdio(pipes) => &pipes.streamer,
635 #[cfg(feature = "testing")]
636 StdioLink::InProcess(pipe) => {
637 let key = StreamKey::new(msg.name())
638 .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
639 pipe.write(key, wire::encode(msg.headers(), msg.payload(), true));
640 return Ok(());
641 }
642 };
643 let producer = self
644 .producer
645 .get_or_try_init(async || {
646 streamer
647 .create_generic_producer(StdioProducerOptions::default())
648 .await
649 .map_err(|e| SeaFileError::Publish {
650 stream: msg.name().to_owned(),
651 source: box_err(e),
652 })
653 })
654 .await?;
655 let key = StreamKey::new(msg.name())
656 .map_err(|e| SeaFileError::Invalid(format!("'{}': {e}", msg.name())))?;
657 // force_text: the stdio line format rejects non-UTF-8 payloads.
658 let payload = wire::encode(msg.headers(), msg.payload(), true);
659 producer
660 .send_to(&key, payload.as_slice())
661 .map_err(|e| SeaFileError::Publish {
662 stream: msg.name().to_owned(),
663 source: box_err(e),
664 })?
665 .await
666 .map(|_| ())
667 .map_err(|e| SeaFileError::Publish {
668 stream: msg.name().to_owned(),
669 source: box_err(e),
670 })
671 }
672}
673
674/// The publish policy for [`StdioPublisher`].
675///
676/// # Examples
677///
678/// ```
679/// use ruststream_sea_file::prelude::*;
680/// use serde::{Deserialize, Serialize};
681///
682/// #[derive(Debug, Deserialize)]
683/// struct Order {
684/// id: u64,
685/// }
686///
687/// #[derive(Debug, Outgoing, Serialize)]
688/// struct Receipt {
689/// order: u64,
690/// }
691///
692/// #[subscriber("orders", reply("receipts"))]
693/// async fn confirm(order: &Order) -> Receipt {
694/// Receipt { order: order.id }
695/// }
696///
697/// #[ruststream::app]
698/// fn app() -> impl App {
699/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(StdioBroker::new(), |b| {
700/// b.include(confirm)
701/// .out_reply(StdioPublish)
702/// .out_retry(StdioPublish)
703/// .to("orders.retry");
704/// })
705/// }
706/// ```
707#[derive(Debug, Clone, Copy, Default)]
708#[must_use]
709pub struct StdioPublish;
710
711impl PublishPolicy<ConnectedStdioBroker> for StdioPublish {
712 type Live = StdioPublisher;
713
714 fn pair(
715 self,
716 connected: &ConnectedStdioBroker,
717 ) -> impl Future<Output = Result<Self::Live, PairError>> {
718 ready(Ok(connected.publisher()))
719 }
720}
721
722/// The publish policy of this form, under the name every form uses.
723///
724/// A mount site names the concept, never the transport: moving a service from one form to
725/// another changes the prelude it globs and leaves the composition root alone. The prefixed
726/// [`StdioPublish`] stays at the crate root, for a service that mixes both forms.
727///
728/// # Examples
729///
730/// ```
731/// use ruststream_sea_file::stdio::prelude::*;
732/// use serde::{Deserialize, Serialize};
733///
734/// #[derive(Debug, Deserialize)]
735/// struct Order {
736/// id: u64,
737/// }
738///
739/// #[derive(Debug, Outgoing, Serialize)]
740/// struct Receipt {
741/// order: u64,
742/// }
743///
744/// #[subscriber("orders", reply("receipts"))]
745/// async fn confirm(order: &Order) -> Receipt {
746/// Receipt { order: order.id }
747/// }
748///
749/// #[ruststream::app]
750/// fn app() -> impl App {
751/// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(StdioBroker::new(), |b| {
752/// b.include(confirm)
753/// .out_reply(Publish)
754/// .out_retry(Publish)
755/// .to("orders.retry");
756/// })
757/// }
758/// ```
759pub use StdioPublish as Publish;
760
761pub mod prelude {
762 //! The imports a service on a shell pipeline writes every time, in one glob.
763 //!
764 //! The framework's prelude, this form's broker, and [`Publish`]. A subscription here is a
765 //! plain stream key, so this form has no descriptor type.
766 //!
767 //! This is the routes-side vocabulary: a mount site globs it and names policies by concept.
768 //! A handler body globs `ruststream::prelude` instead and bounds an injected publisher by
769 //! the framework's capability traits, so the two vocabularies never meet in one file.
770 //!
771 //! # Examples
772 //!
773 //! ```
774 //! use ruststream_sea_file::stdio::prelude::*;
775 //! use serde::{Deserialize, Serialize};
776 //!
777 //! #[derive(Debug, Deserialize)]
778 //! struct Job {
779 //! id: u64,
780 //! }
781 //!
782 //! #[derive(Debug, Outgoing, Serialize)]
783 //! #[outgoing(name = "results")]
784 //! struct Done {
785 //! id: u64,
786 //! }
787 //!
788 //! #[subscriber("jobs", reply)]
789 //! async fn work(job: &Job) -> Done {
790 //! Done { id: job.id }
791 //! }
792 //!
793 //! #[ruststream::app]
794 //! fn app() -> impl App {
795 //! RustStream::new(AppInfo::new("pipeline", "0.1.0"))
796 //! .with_broker(StdioBroker::new(), |b| {
797 //! b.include(work).out_retry(Publish).to("jobs.retry");
798 //! })
799 //! }
800 //! ```
801
802 pub use ruststream::prelude::*;
803
804 // No capability traits: `SeaMessage` implements `Positioned`, but a pipe cannot seek back to
805 // one - do not add it here.
806 pub use crate::stdio::{Publish, StdioBroker};
807}