Skip to main content

asimov_runner/
pipeline.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Linear pipelines of graph-compatible programs.
4//!
5//! [`Pipeline::new`] consumes a configured program; [`Pipeline::pipe`] appends a
6//! graph consumer. Connections carry JSON-LD over JSONL by contract, without
7//! parsing or transcoding. Native edges use cross-platform [`std::io::pipe`]
8//! handles connected directly to child stdin/stdout: no shell, platform-specific
9//! handle code, or full intermediate-output buffer is involved. Tokio drives
10//! boundary I/O, stderr draining, and process supervision.
11//!
12//! # Reader → Writer
13//!
14//! ```no_run
15//! use asimov_runner::{AnyInput, AnyOutput, GraphInput, GraphOutput, Pipeline, Reader, Writer};
16//! use std::io::Cursor;
17//!
18//! # async fn example() -> Result<(), asimov_runner::PipelineError> {
19//! let reader = Reader::new(
20//!     "asimov-example-reader",
21//!     AnyInput::AsyncRead(Box::new(Cursor::new(b"source document".to_vec()))),
22//!     GraphOutput::Captured,
23//!     Default::default(),
24//! );
25//! let writer = Writer::new(
26//!     "asimov-example-writer", GraphInput::Ignored, AnyOutput::Captured, Default::default(),
27//! );
28//! let bytes = Pipeline::new(reader).pipe(writer).execute().await?.into_inner();
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! # Fetcher → Reasoner → Indexer
34//!
35//! ```no_run
36//! use asimov_runner::{Fetcher, GraphInput, GraphOutput, Indexer, IndexerOptions, Pipeline, Reasoner};
37//!
38//! # async fn example() -> Result<(), asimov_runner::PipelineError> {
39//! let fetcher = Fetcher::new(
40//!     "asimov-example-fetcher", "https://example.com/resource",
41//!     GraphOutput::Captured, Default::default(),
42//! );
43//! let reasoner = Reasoner::new(
44//!     "asimov-example-reasoner", GraphInput::Ignored, GraphOutput::Captured, Default::default(),
45//! );
46//! let indexer = Indexer::new(
47//!     "asimov-example-indexer", GraphInput::Ignored,
48//!     IndexerOptions::builder().other("./catalog.index").build(),
49//! );
50//! Pipeline::new(fetcher).pipe(reasoner).pipe(indexer).execute().await?;
51//! # Ok(())
52//! # }
53//! ```
54//!
55//! # Routing and completion
56//!
57//! A pipeline is a one-shot owner of its configured programs. Only the first
58//! program supplies external input; later programs must use `Input::Ignored` as
59//! the placeholder replaced by the connection. Intermediate stdout routing is
60//! replaced by pipes; an intermediate `Output::AsyncWrite` is rejected rather
61//! than silently discarding its writer. The final program's output policy is
62//! honored. Explicit graph format options must be `jsonl` (or left unset).
63//! Programs must agree on a JSON-LD profile and use the connected standard streams;
64//! arbitrary `other` arguments and file operands are not interpreted by this API.
65//!
66//! Graph-producing tails return a live [`PipelineStream`] of [`crate::JsonlBatch`]
67//! values. [`Pipeline::with_batching`] overrides the tail program's default
68//! batching policy; intermediate native pipe edges remain byte streams. A [`Writer`] tail
69//! returns buffered arbitrary-format bytes, and an [`Indexer`] tail returns `()`.
70//! Success requires every stage to complete successfully, not just the tail.
71//! Failures include their zero-based stage index and executable. The first
72//! observed failure is reported, preferring downstream stages when multiple
73//! outcomes are ready. Other directly supervised children are terminated and reaped before
74//! returning that failure. Dropping execution or its stream requests termination
75//! through each owned child's kill-on-drop policy; it does not synchronously reap
76//! children, terminate arbitrary descendants, or roll back external side effects.
77//!
78//! All stages spawn before output is consumed, and command-owned copies of pipe
79//! endpoints are released immediately after spawning. Polling the execution or
80//! returned stream drives supervision and boundary I/O; no detached tasks are
81//! created. Stderr and buffered final output have no configured size bound.
82//! Graph batch sizes and collection delay follow [`BatchOptions`]. EOF flushes
83//! a partial batch. Already-read complete lines are delivered before a terminal
84//! error, without delaying cleanup once that error is observed.
85//!
86//! A limited [`Lister`] applies its line cap before producing batches at the first edge
87//! so native pipe wiring cannot bypass the runner's limit. That edge is relayed
88//! with backpressure through `Input::Jsonl` (which terminates unterminated input
89//! lines with LF); other edges remain direct OS pipes. A zero-limit lister starts
90//! no source child and supplies EOF downstream. Reaching its limit intentionally
91//! cancels that source without checking its eventual exit status, as for standalone
92//! lister execution, including its kill-on-drop/reaping policy. Native pipes
93//! otherwise preserve bytes exactly. Neither a
94//! successful exit nor writing to a pipe proves application-level processing.
95//!
96//! # Batch-oriented postprocessing
97//!
98//! ```no_run
99//! use asimov_runner::{BatchOptions, Fetcher, GraphOutput, Pipeline, StreamExt};
100//! use std::time::Duration;
101//!
102//! # async fn example() -> Result<(), asimov_runner::PipelineError> {
103//! let fetcher = Fetcher::new(
104//!     "asimov-example-fetcher", "https://example.com/resource",
105//!     GraphOutput::Captured, Default::default(),
106//! );
107//! let policy = BatchOptions::new(128, 64 * 1024, Duration::from_millis(5))
108//!     .expect("nonzero thresholds");
109//! let mut batches = Pipeline::new(fetcher).with_batching(policy).execute().await?;
110//! while let Some(batch) = batches.next().await {
111//!     let batch = batch?;
112//!     // Submit the whole batch to a network service, or iterate batch.lines().
113//! }
114//! # Ok(())
115//! # }
116//! ```
117
118use crate::batch::{FrameStream, batch_frames};
119use crate::{
120    BatchOptions, BatchStream, Executor, ExecutorError, Indexer, Input, InputCompletion, Lister,
121    Output, StreamExt, Writer,
122};
123use alloc::{boxed::Box, vec, vec::Vec};
124use core::{
125    fmt,
126    future::{Future, poll_fn},
127    marker::PhantomData,
128    pin::Pin,
129    task::Poll,
130};
131use std::{
132    ffi::OsString,
133    io::{self, Cursor},
134    process::Stdio,
135};
136use tokio::{process::Child, sync::watch};
137
138/// A pipeline failure attributed to a configured program.
139#[derive(Debug)]
140pub struct PipelineError {
141    /// Zero-based stage index, in construction order.
142    pub stage: usize,
143    /// Executable name or resolved path used for that stage.
144    pub program: OsString,
145    /// Configuration, spawn, transport, input, or exit failure.
146    pub error: ExecutorError,
147}
148
149impl fmt::Display for PipelineError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(
152            f,
153            "Pipeline stage {} ({}): {}",
154            self.stage,
155            self.program.to_string_lossy(),
156            self.error
157        )
158    }
159}
160
161impl core::error::Error for PipelineError {
162    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
163        Some(&self.error)
164    }
165}
166
167/// Live final graph batches; consume to EOF to check every stage's outcome.
168/// Dropping the stream requests termination of all owned children.
169pub type PipelineStream = BatchStream<PipelineError>;
170
171mod sealed {
172    pub trait Sealed {}
173}
174
175/// A built-in program that can participate in a linear graph pipeline.
176/// This trait is sealed; use the configured runner types to construct stages.
177pub trait PipelineProgram: sealed::Sealed + Into<PipelineStage> {}
178
179/// A program whose output can supply a graph consumer's stdin.
180pub trait GraphProducer: PipelineProgram {}
181
182/// A program accepting JSONL graph input from a preceding stage.
183pub trait GraphConsumer: PipelineProgram {}
184
185macro_rules! programs {
186    (producer: $($producer:ty),*; consumer: $($consumer:ty),*; both: $($both:ty),*) => {
187        $(impl sealed::Sealed for $producer {}
188          impl PipelineProgram for $producer {}
189          impl GraphProducer for $producer {})*
190        $(impl sealed::Sealed for $consumer {}
191          impl PipelineProgram for $consumer {}
192          impl GraphConsumer for $consumer {})*
193        $(impl sealed::Sealed for $both {}
194          impl PipelineProgram for $both {}
195          impl GraphProducer for $both {}
196          impl GraphConsumer for $both {})*
197    };
198}
199programs! {
200    producer: crate::Adapter, crate::Emitter, crate::Fetcher, Lister, crate::Reader;
201    consumer: Writer, Indexer;
202    both: crate::Matcher, crate::Reasoner
203}
204
205/// An owned, nonempty linear pipeline, typed by its final program.
206///
207/// Only graph producers can be followed by graph consumers; writer and indexer
208/// tails terminate construction. For example, a fetcher cannot consume graph input:
209///
210/// ```compile_fail
211/// use asimov_runner::{Fetcher, GraphOutput, Pipeline};
212/// let a = Fetcher::new("a", "example:", GraphOutput::Captured, Default::default());
213/// let b = Fetcher::new("b", "example:", GraphOutput::Captured, Default::default());
214/// let invalid = Pipeline::new(a).pipe(b);
215/// ```
216///
217/// A writer is a terminal, arbitrary-format output stage, not a graph producer:
218///
219/// ```compile_fail
220/// use asimov_runner::{Input, Output, Pipeline, Reasoner, Writer};
221/// let writer = Writer::new("writer", Input::Ignored, Output::Captured, Default::default());
222/// let reasoner = Reasoner::new("reasoner", Input::Ignored, Output::Captured, Default::default());
223/// let invalid = Pipeline::new(writer).pipe(reasoner);
224/// ```
225#[derive(Debug)]
226pub struct Pipeline<P> {
227    stages: Vec<PipelineStage>,
228    batching: Option<BatchOptions>,
229    tail: PhantomData<fn() -> P>,
230}
231
232impl<P: PipelineProgram> Pipeline<P> {
233    /// Starts a pipeline without spawning processes. Consumes the program and
234    /// preserves its arguments, source input, and final-output configuration.
235    pub fn new(program: P) -> Self {
236        Self {
237            stages: vec![program.into()],
238            batching: None,
239            tail: PhantomData,
240        }
241    }
242}
243
244impl<P: GraphProducer> Pipeline<P> {
245    /// Appends a graph consumer. Its `Input::Ignored` is replaced by a pipe from
246    /// the preceding program. No processes are started during construction.
247    pub fn pipe<N: GraphConsumer>(mut self, program: N) -> Pipeline<N> {
248        self.stages.push(program.into());
249        Pipeline {
250            stages: self.stages,
251            batching: self.batching,
252            tail: PhantomData,
253        }
254    }
255
256    /// Overrides batching for the final Rust-facing graph stream. Native pipe
257    /// edges are unchanged. This override survives `pipe` calls; otherwise the
258    /// final program's batching policy is used. Byte/unit tails do not batch output.
259    #[must_use]
260    pub fn with_batching(mut self, options: BatchOptions) -> Self {
261        self.batching = Some(options);
262        self
263    }
264
265    /// Spawns the pipeline and returns final JSONL batches without waiting for exit.
266    /// Configuration/launch errors are returned directly; later failures are
267    /// final stream items after any buffered complete lines. Non-captured final
268    /// output yields no payload batches.
269    pub async fn execute(self) -> Result<PipelineStream, PipelineError> {
270        let batching = self
271            .batching
272            .unwrap_or(self.stages.last().unwrap().batching);
273        Ok(batch_frames(self.execute_frames().await?, batching))
274    }
275
276    async fn execute_frames(self) -> Result<FrameStream<PipelineError>, PipelineError> {
277        let mut running = start(self.stages, true).await?;
278        let mut lines = running.lines.take();
279        let tail = running.tail.clone();
280        Ok(Box::pin(async_stream::try_stream! {
281            let mut completed = false;
282            if let Some(ref mut lines) = lines {
283                loop {
284                    let event = tokio::select! {
285                        result = running.wait(), if !completed => {
286                            completed = true;
287                            result.map(|_| None)
288                        },
289                        line = lines.next() => Ok(Some(line)),
290                    }?;
291                    let Some(line) = event else { continue };
292                    match line {
293                        Some(Ok(line)) => yield line,
294                        Some(Err(error)) => {
295                            running.cancel().await;
296                            Err(tail.error(error))?;
297                        },
298                        None => break,
299                    }
300                }
301            }
302            if !completed {
303                running.wait().await?;
304            }
305        }))
306    }
307}
308
309impl Pipeline<Writer> {
310    /// Runs every stage and returns the writer's captured arbitrary-format bytes.
311    /// Forwarded, ignored, and inherited stdout return an empty cursor. Any stage
312    /// failure fails the pipeline, even if the writer exits successfully.
313    pub async fn execute(self) -> Result<Cursor<Vec<u8>>, PipelineError> {
314        let mut running = start(self.stages, false).await?;
315        Ok(Cursor::new(running.wait().await?))
316    }
317}
318
319impl Pipeline<Indexer> {
320    /// Runs every stage and waits for successful indexing and upstream completion.
321    pub async fn execute(self) -> Result<(), PipelineError> {
322        start(self.stages, false).await?.wait().await?;
323        Ok(())
324    }
325}
326
327/// Opaque owned stage configuration used by the sealed pipeline traits.
328#[doc(hidden)]
329#[derive(Debug)]
330pub struct PipelineStage {
331    program: OsString,
332    kind: StageKind,
333    error: Option<ExecutorError>,
334    external_input: bool,
335    external_writer: bool,
336    batching: BatchOptions,
337}
338
339#[derive(Debug)]
340enum StageKind {
341    Native {
342        executor: Executor,
343        input: Input,
344        output: Output,
345    },
346    LimitedLister(Box<Lister>),
347}
348
349impl PipelineStage {
350    pub(crate) fn native(
351        mut executor: Executor,
352        input: Input,
353        output: Output,
354        error: Option<ExecutorError>,
355    ) -> Self {
356        Self {
357            program: executor.command().as_std().get_program().to_os_string(),
358            batching: executor.batch_options(),
359            external_input: !matches!(input, Input::Ignored),
360            external_writer: matches!(output, Output::AsyncWrite(_)),
361            kind: StageKind::Native {
362                executor,
363                input,
364                output,
365            },
366            error,
367        }
368    }
369
370    pub(crate) fn limited_lister(
371        lister: Lister,
372        program: OsString,
373        external_writer: bool,
374        error: Option<ExecutorError>,
375        batching: BatchOptions,
376    ) -> Self {
377        Self {
378            program,
379            kind: StageKind::LimitedLister(Box::new(lister)),
380            error,
381            external_input: false,
382            external_writer,
383            batching,
384        }
385    }
386}
387
388pub(crate) fn graph_formats(input: Option<&str>, output: Option<&str>) -> Option<ExecutorError> {
389    for (option, format) in [("input", input), ("output", output)] {
390        if let Some(format) = format {
391            if format != "jsonl" {
392                return Some(invalid(alloc::format!(
393                    "pipeline graph {option} format must be jsonl, got {format}"
394                )));
395            }
396        }
397    }
398    None
399}
400
401fn invalid(message: impl Into<alloc::string::String>) -> ExecutorError {
402    io::Error::new(io::ErrorKind::InvalidInput, message.into()).into()
403}
404
405macro_rules! stage {
406    ($program:ty, $value:ident, $input:expr, $output:expr, $input_format:expr, $output_format:expr) => {
407        impl From<$program> for crate::pipeline::PipelineStage {
408            fn from($value: $program) -> Self {
409                let error = crate::pipeline::graph_formats($input_format, $output_format);
410                Self::native($value.executor, $input, $output, error)
411            }
412        }
413    };
414}
415pub(crate) use stage;
416
417#[derive(Clone)]
418struct StageInfo {
419    index: usize,
420    program: OsString,
421}
422
423impl StageInfo {
424    fn error(&self, error: impl Into<ExecutorError>) -> PipelineError {
425        PipelineError {
426            stage: self.index,
427            program: self.program.clone(),
428            error: error.into(),
429        }
430    }
431}
432
433type Job = Pin<Box<dyn Future<Output = Result<Vec<u8>, PipelineError>> + Send>>;
434
435struct Running {
436    jobs: Vec<Option<Job>>,
437    stop: watch::Sender<bool>,
438    lines: Option<FrameStream>,
439    tail: StageInfo,
440    failure: Option<PipelineError>,
441    output: Vec<u8>,
442}
443
444impl Running {
445    // State lives in Running, not this future: selecting on wait while reading
446    // final output can cancel the future without losing completed jobs or errors.
447    async fn next(&mut self) -> Option<(usize, Result<Vec<u8>, PipelineError>)> {
448        poll_fn(|cx| {
449            let mut pending = false;
450            for (index, job) in self.jobs.iter_mut().enumerate().rev() {
451                if let Some(future) = job {
452                    match future.as_mut().poll(cx) {
453                        Poll::Ready(result) => {
454                            *job = None;
455                            return Poll::Ready(Some((index, result)));
456                        },
457                        Poll::Pending => pending = true,
458                    }
459                }
460            }
461            if pending {
462                Poll::Pending
463            } else {
464                Poll::Ready(None)
465            }
466        })
467        .await
468    }
469
470    async fn wait(&mut self) -> Result<Vec<u8>, PipelineError> {
471        while let Some((index, result)) = self.next().await {
472            match result {
473                Ok(bytes) if index + 1 == self.jobs.len() => self.output = bytes,
474                Ok(_) => {},
475                Err(error) if self.failure.is_none() => {
476                    self.failure = Some(error);
477                    self.stop.send_replace(true);
478                },
479                Err(_) => {},
480            }
481        }
482        match self.failure.take() {
483            Some(error) => Err(error),
484            None => Ok(core::mem::take(&mut self.output)),
485        }
486    }
487
488    async fn cancel(&mut self) {
489        self.stop.send_replace(true);
490        while self.next().await.is_some() {}
491    }
492}
493
494struct Spawned {
495    child: Child,
496    input: Input,
497    output: Output,
498    info: StageInfo,
499}
500
501async fn run_stage(
502    mut stage: Spawned,
503    mut stop: watch::Receiver<bool>,
504    source: Option<StageInfo>,
505) -> Result<Vec<u8>, PipelineError> {
506    let cancelled = *stop.borrow();
507    let completion = if cancelled {
508        None
509    } else {
510        tokio::select! {
511            biased;
512            result = crate::executor::communicate_child(&mut stage.child, &mut stage.input, &mut stage.output) => Some(result),
513            _ = stop.changed() => None,
514        }
515    };
516    match completion {
517        Some(result) => {
518            let completion = result.map_err(|error| stage.info.error(error))?;
519            let info = if matches!(completion.input, InputCompletion::SourceFailed(_)) {
520                source.as_ref().unwrap_or(&stage.info)
521            } else {
522                &stage.info
523            };
524            completion
525                .into_result()
526                .map(Cursor::into_inner)
527                .map_err(|error| info.error(error))
528        },
529        None => {
530            let _ = stage.child.start_kill();
531            let _ = stage.child.wait().await;
532            Ok(Vec::new())
533        },
534    }
535}
536
537async fn start(
538    mut stages: Vec<PipelineStage>,
539    capture_graph: bool,
540) -> Result<Running, PipelineError> {
541    let count = stages.len();
542    // Validate the entire chain before starting any process or consuming a source.
543    for (index, stage) in stages.iter_mut().enumerate() {
544        let info = StageInfo {
545            index,
546            program: stage.program.clone(),
547        };
548        if let Some(error) = stage.error.take() {
549            return Err(info.error(error));
550        }
551        if index != 0 && stage.external_input {
552            return Err(info.error(invalid(
553                "piped stages must use Input::Ignored; their input comes from the preceding stage",
554            )));
555        }
556        if index + 1 != count && stage.external_writer {
557            return Err(info.error(invalid("an intermediate pipeline stage cannot also forward stdout to an AsyncWrite destination")));
558        }
559    }
560    let tail = StageInfo {
561        index: count - 1,
562        program: stages.last().unwrap().program.clone(),
563    };
564    let (stop, receiver) = watch::channel(false);
565    let mut running = Running {
566        jobs: Vec::new(),
567        stop,
568        lines: None,
569        tail,
570        failure: None,
571        output: Vec::new(),
572    };
573    let mut source_info = None;
574    let mut limited_source = None;
575    if matches!(stages[0].kind, StageKind::LimitedLister(_)) {
576        let stage = stages.remove(0);
577        let info = StageInfo {
578            index: 0,
579            program: stage.program,
580        };
581        let StageKind::LimitedLister(mut lister) = stage.kind else {
582            unreachable!()
583        };
584        if stages.is_empty() {
585            running.lines = Some(
586                lister
587                    .execute_frames()
588                    .await
589                    .map_err(|error| info.error(error))?,
590            );
591            return Ok(running);
592        }
593        limited_source = Some(lister);
594        source_info = Some(info);
595    }
596    let base = usize::from(source_info.is_some());
597    for index in 0..stages.len().saturating_sub(1) {
598        let (reader, writer) = io::pipe().map_err(|error| {
599            StageInfo {
600                index: index + base,
601                program: stages[index].program.clone(),
602            }
603            .error(error)
604        })?;
605        let StageKind::Native { executor, .. } = &mut stages[index].kind else {
606            unreachable!()
607        };
608        executor.command().stdout(Stdio::from(writer));
609        let StageKind::Native { executor, .. } = &mut stages[index + 1].kind else {
610            unreachable!()
611        };
612        executor.command().stdin(Stdio::from(reader));
613    }
614    if let Some(lister) = limited_source {
615        let source = lister
616            .into_pipeline_source()
617            .await
618            .map_err(|error| source_info.as_ref().unwrap().error(error))?;
619        let StageKind::Native {
620            executor, input, ..
621        } = &mut stages[0].kind
622        else {
623            unreachable!()
624        };
625        *input = Input::Jsonl(source);
626        executor.command().stdin(input.as_stdio());
627    }
628    let mut spawned: Vec<Spawned> = Vec::new();
629    let mut plans = stages.into_iter().enumerate();
630    while let Some((index, stage)) = plans.next() {
631        let info = StageInfo {
632            index: index + base,
633            program: stage.program,
634        };
635        let StageKind::Native {
636            mut executor,
637            input,
638            mut output,
639        } = stage.kind
640        else {
641            unreachable!()
642        };
643        if info.index + 1 != count {
644            output = Output::Ignored;
645        }
646        let result = executor.spawn().await;
647        // Command stores pipe handles too. Keeping it alive would prevent EOF.
648        drop(executor);
649        let mut child = match result {
650            Ok(child) => child,
651            Err(error) => {
652                drop(plans);
653                drop(input);
654                for stage in &mut spawned {
655                    stage.input = Input::Ignored;
656                    let _ = stage.child.start_kill();
657                }
658                for stage in &mut spawned {
659                    let _ = stage.child.wait().await;
660                }
661                return Err(info.error(error));
662            },
663        };
664        if info.index + 1 == count && capture_graph && matches!(output, Output::Captured) {
665            running.lines = child.stdout.take().map(crate::jsonl::jsonl_frames);
666        }
667        spawned.push(Spawned {
668            child,
669            input,
670            output,
671            info,
672        });
673    }
674    for (index, stage) in spawned.into_iter().enumerate() {
675        running.jobs.push(Some(Box::pin(run_stage(
676            stage,
677            receiver.clone(),
678            if index == 0 {
679                source_info.clone()
680            } else {
681                None
682            },
683        ))));
684    }
685    Ok(running)
686}