Skip to main content

media_pp/core/
graph.rs

1//! The pipeline's topology, recorded separately from the elements themselves.
2//!
3//! Elements own their pads and their downstream peers; nothing in that chain
4//! can answer "what does this pipeline look like right now". [`PipelineGraph`]
5//! keeps that record: stable [`ElementId`]/[`EdgeId`]/[`BranchId`] values that
6//! survive same-name churn, and [`GraphSnapshot`] as a consistent copy to read
7//! outside the lock.
8//!
9//! Names are labels only. IDs are what attachment, detachment, and lookup use,
10//! and what lets a log record name one specific element when several share a
11//! name.
12
13use std::{
14    collections::{HashMap, HashSet},
15    fmt::{self, Write as _},
16    sync::{Arc, Mutex},
17};
18
19use thiserror::Error as ThisError;
20
21use crate::{
22    contract::{InputContract, OutputContract, PortContract},
23    element::ElementType,
24    log::{Level, enabled},
25    pp_log::{PpLog, pp_info},
26};
27
28/// Stable identity of one element inside a pipeline graph. Names are only
29/// labels; IDs are what graph mutation and lookup use.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct ElementId(u64);
32
33impl ElementId {
34    #[cfg(test)]
35    pub(crate) const fn for_test(value: u64) -> Self {
36        Self(value)
37    }
38}
39
40impl fmt::Display for ElementId {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        self.0.fmt(f)
43    }
44}
45
46/// Stable identity of one connection between two element ports.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
48pub struct EdgeId(u64);
49
50/// Identity of one attached branch. A branch owns every node and edge that
51/// arrived in the same attachment transaction.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
53pub struct BranchId(u64);
54
55impl fmt::Display for BranchId {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        self.0.fmt(f)
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62/// One element as it appears in a [`GraphSnapshot`] — its stable identity, its
63/// type, and the name its caller chose.
64pub struct NodeInfo {
65    /// Stable identity assigned by the owning pipeline graph.
66    pub id: ElementId,
67    /// Built-in kind reported by the element.
68    pub element_type: ElementType,
69    /// Caller-selected instance name; names need not be unique within a graph.
70    pub name: Arc<str>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74/// One end of an edge: the element, and the name of the pad on it.
75pub struct PortRef {
76    /// Stable identity of the element that owns this port.
77    pub element: ElementId,
78    /// Element-defined port name used in topology output.
79    pub port: Arc<str>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83/// One link from a source pad to a downstream element, together with the
84/// branch whose attachment created it.
85pub struct EdgeInfo {
86    /// Stable identity of this connection.
87    pub id: EdgeId,
88    /// Attachment transaction that created this edge.
89    pub branch_id: BranchId,
90    /// Upstream source pad.
91    pub from: PortRef,
92    /// Downstream sink port.
93    pub to: PortRef,
94}
95
96#[derive(Debug, Clone)]
97/// A consistent copy of the whole topology, taken under the graph's lock and
98/// then read without it.
99///
100/// `revision` increments on every mutation, so two snapshots can be told apart
101/// even when they describe the same shape. This is also what
102/// [`crate::pipeline::Pipeline::topology`] renders and what a `run`/`attach`/
103/// `detach` log record embeds.
104pub struct GraphSnapshot {
105    /// Monotonically increasing graph version. Each successful mutation
106    /// increments it exactly once.
107    pub revision: u64,
108    /// Elements attached when this snapshot was taken.
109    pub nodes: Vec<NodeInfo>,
110    /// Connections attached when this snapshot was taken.
111    pub edges: Vec<EdgeInfo>,
112}
113
114impl GraphSnapshot {
115    /// Finds one node by stable identity within this snapshot.
116    pub fn node(&self, id: ElementId) -> Option<&NodeInfo> {
117        self.nodes.iter().find(|node| node.id == id)
118    }
119
120    /// Returns the attached terminal sinks in this snapshot.
121    ///
122    /// A terminal has an incoming edge and no outgoing edge. An empty dynamic
123    /// [`crate::elements::Tee`] is excluded: it is a leaf in the drawing, but
124    /// has no terminal sink that can acknowledge preroll.
125    pub fn terminal_ids(&self) -> Vec<ElementId> {
126        self.nodes
127            .iter()
128            .filter(|node| {
129                node.element_type != ElementType::Tee
130                    && self.edges.iter().any(|edge| edge.to.element == node.id)
131                    && !self.edges.iter().any(|edge| edge.from.element == node.id)
132            })
133            .map(|node| node.id)
134            .collect()
135    }
136
137    /// Returns terminal sinks reachable from `root` in this snapshot.
138    ///
139    /// Used by `Tee` during preroll so a branch that has already delivered
140    /// all of its terminal samples can close without backpressuring sibling
141    /// branches that still need more input.
142    pub(crate) fn terminal_ids_from(&self, root: ElementId) -> Vec<ElementId> {
143        let terminals: HashSet<_> = self.terminal_ids().into_iter().collect();
144        let mut found = Vec::new();
145        let mut visiting = vec![root];
146        let mut visited = HashSet::new();
147        while let Some(current) = visiting.pop() {
148            if !visited.insert(current) {
149                continue;
150            }
151            if terminals.contains(&current) {
152                found.push(current);
153                continue;
154            }
155            visiting.extend(
156                self.edges
157                    .iter()
158                    .filter(|edge| edge.from.element == current)
159                    .map(|edge| edge.to.element),
160            );
161        }
162        found
163    }
164
165    /// Renders every root-to-leaf path in insertion order. Keeping edges
166    /// separate from nodes means this also remains meaningful for fan-in
167    /// graphs, where a node can have more than one upstream.
168    pub fn topology(&self) -> String {
169        self.paths()
170            .into_iter()
171            .map(|path| {
172                path.into_iter()
173                    .filter_map(|id| self.node(id))
174                    .map(|node| format!("{:?}({})", node.element_type, node.name))
175                    .collect::<Vec<_>>()
176                    .join(" - ")
177            })
178            .collect::<Vec<_>>()
179            .join("\n")
180    }
181
182    /// Logging-only flow diagram. Each child connector starts under its
183    /// upstream element, so a fan-out is visible at the element where it
184    /// actually occurs instead of repeating the common path for every leaf.
185    pub(crate) fn topology_diagram(&self) -> String {
186        let roots: Vec<_> = self
187            .nodes
188            .iter()
189            .filter(|node| !self.edges.iter().any(|edge| edge.to.element == node.id))
190            .collect();
191        let mut output = String::new();
192
193        for (index, root) in roots.iter().enumerate() {
194            let is_last = index + 1 == roots.len();
195            let child_indent = if roots.len() == 1 {
196                let _ = write!(output, "{:?}({})#{}", root.element_type, root.name, root.id);
197                String::new()
198            } else {
199                let connector = if is_last { "└── " } else { "├── " };
200                let _ = write!(
201                    output,
202                    "{connector}{:?}({})#{}",
203                    root.element_type, root.name, root.id
204                );
205                if is_last {
206                    "    ".to_owned()
207                } else {
208                    "│   ".to_owned()
209                }
210            };
211            self.render_diagram_children(
212                root.id,
213                &child_indent,
214                &mut HashSet::from([root.id]),
215                &mut output,
216            );
217            if !is_last {
218                output.push('\n');
219            }
220        }
221
222        output
223    }
224
225    fn render_diagram_children(
226        &self,
227        parent: ElementId,
228        indent: &str,
229        visiting: &mut HashSet<ElementId>,
230        output: &mut String,
231    ) {
232        let children: Vec<_> = self
233            .edges
234            .iter()
235            .filter(|edge| edge.from.element == parent)
236            .collect();
237
238        for (index, edge) in children.iter().enumerate() {
239            let Some(child) = self.node(edge.to.element) else {
240                continue;
241            };
242            let is_last = index + 1 == children.len();
243            let connector = if is_last { "└── " } else { "├── " };
244            let link = format!("[{}] → ", edge.from.port);
245            let _ = write!(
246                output,
247                "\n{indent}{connector}{link}{:?}({})#{}",
248                child.element_type, child.name, child.id
249            );
250
251            if visiting.insert(child.id) {
252                let continuation = if is_last { "    " } else { "│   " };
253                let child_indent =
254                    format!("{indent}{continuation}{}", " ".repeat(link.chars().count()));
255                self.render_diagram_children(child.id, &child_indent, visiting, output);
256                visiting.remove(&child.id);
257            }
258        }
259    }
260
261    fn paths(&self) -> Vec<Vec<ElementId>> {
262        let leaves: Vec<_> = self
263            .nodes
264            .iter()
265            .filter(|node| !self.edges.iter().any(|edge| edge.from.element == node.id))
266            .collect();
267        let mut rendered = Vec::new();
268        for leaf in leaves {
269            self.paths_to(leaf.id, &mut HashSet::new(), &mut Vec::new(), &mut rendered);
270        }
271        rendered
272    }
273
274    fn paths_to(
275        &self,
276        current: ElementId,
277        visiting: &mut HashSet<ElementId>,
278        suffix: &mut Vec<ElementId>,
279        paths: &mut Vec<Vec<ElementId>>,
280    ) {
281        if !visiting.insert(current) {
282            return;
283        }
284        suffix.push(current);
285        let upstream: Vec<_> = self
286            .edges
287            .iter()
288            .filter(|edge| edge.to.element == current)
289            .map(|edge| edge.from.element)
290            .collect();
291        if upstream.is_empty() {
292            let mut path = suffix.clone();
293            path.reverse();
294            paths.push(path);
295        } else {
296            for parent in upstream {
297                self.paths_to(parent, visiting, suffix, paths);
298            }
299        }
300        suffix.pop();
301        visiting.remove(&current);
302    }
303}
304
305/// Emits `event` and the topology it produced as **one** record: the event
306/// word on the header line, the diagram in the body.
307///
308/// They cannot be two records. The private logger queues each `write_all`
309/// separately, so only the lines inside a single record are guaranteed to stay
310/// together — any live thread (a `Queue` worker this very call just started,
311/// say) can write between two of them. Emitting the diagram separately would
312/// mean it is merely *usually* adjacent to the event that caused it.
313pub(crate) fn log_topology(pp_log: &PpLog, event: &str, snapshot: &GraphSnapshot) {
314    if !enabled(Level::Info) {
315        return;
316    }
317    pp_info!(pp_log: pp_log, "{event}\n{}", snapshot.topology_diagram());
318}
319
320#[derive(Debug, ThisError, PartialEq, Eq)]
321/// A rejected topology change.
322///
323/// Every variant here is a refusal, not a partial result: attachment validates
324/// before it mutates, so a graph that returns one of these is left exactly as
325/// it was.
326pub enum GraphError {
327    /// The requested source pad index does not exist.
328    #[error("source pad index {index} is out of range (source has {pad_count} pads)")]
329    PadOutOfRange {
330        /// Requested zero-based source pad index.
331        index: usize,
332        /// Number of source pads available on the element.
333        pad_count: usize,
334    },
335
336    /// Attachment targeted a source pad that already owns a downstream sink.
337    #[error("source pad '{0}' is already linked")]
338    PadAlreadyLinked(String),
339
340    /// A dynamic attachment named an element that is no longer in this graph.
341    #[error("element {0} is not attached to this pipeline")]
342    ParentNotAttached(ElementId),
343
344    /// An attachment plan attempted to publish an already-attached node.
345    #[error("element {0} is already attached to this pipeline")]
346    NodeAlreadyAttached(ElementId),
347
348    /// A detach operation named a branch that is no longer attached.
349    #[error("branch {0} is not attached")]
350    BranchNotAttached(BranchId),
351
352    /// A branch cannot join while a seek/lifecycle transaction is taking its
353    /// topology and control snapshots.
354    #[error("a pipeline timeline operation is in progress")]
355    TimelineOperationInProgress,
356
357    /// An attachment plan contained no terminal or processing element.
358    #[error("a branch must contain at least one element")]
359    EmptyBranch,
360
361    /// Two elements were wired together even though what one produces can
362    /// never reach the other — see [`crate::contract`]. Reported when the
363    /// branch is built or attached, before anything runs, because no
364    /// buffer could have made this link work.
365    #[error("{producer} produces {produced}, which {consumer} cannot accept (it takes {accepted})")]
366    IncompatibleLink {
367        /// Name of the element or pad on the producing side.
368        producer: Arc<str>,
369        /// What that side emits.
370        produced: PortContract,
371        /// Caller-selected name of the element that rejected the link.
372        consumer: Arc<str>,
373        /// What the consuming side accepts.
374        accepted: PortContract,
375    },
376
377    /// [`crate::pipeline::ChainBuilder::pipe`] received a filter whose output
378    /// shape cannot be represented as one linear chain stage.
379    #[error("ChainBuilder::pipe requires exactly one output pad, but {name} has {count}")]
380    NotSingleOutput {
381        /// Caller-selected name of the rejected filter.
382        name: Arc<str>,
383        /// Number of source pads exposed by the rejected filter.
384        count: usize,
385    },
386}
387
388#[derive(Debug, Clone)]
389pub(crate) struct PlannedEdge {
390    pub from: PortRef,
391    pub to: PortRef,
392}
393
394/// What is actually flowing along one edge, once every stage upstream of
395/// it has been resolved — together with the element that produced it, so a
396/// rejection names the real producer rather than whichever passthrough
397/// stage last relayed it.
398#[derive(Debug, Clone)]
399pub(crate) struct ResolvedFlow {
400    pub producer: Arc<str>,
401    pub contract: PortContract,
402}
403
404/// Where the contract feeding a new branch comes from.
405#[derive(Debug)]
406pub(crate) enum Incoming {
407    /// The caller already knows it — a source pad states its own.
408    Known(Option<ResolvedFlow>),
409    /// Read it from whatever the parent element was resolved to emit.
410    /// Resolved under the graph lock, so a dynamic attach cannot race the
411    /// transaction that established it.
412    FromParent,
413}
414
415/// One planned element's two contracts, kept per node so a branch can be
416/// re-validated against whatever it eventually gets attached to.
417#[derive(Debug, Clone, Copy)]
418pub(crate) struct PortContracts {
419    pub input: InputContract,
420    pub output: OutputContract,
421}
422
423#[derive(Debug)]
424pub(crate) struct BranchPlan {
425    pub nodes: Vec<NodeInfo>,
426    pub edges: Vec<PlannedEdge>,
427    pub root: ElementId,
428    /// Every node's contracts, keyed by element. A branch is a tree — one
429    /// chain, plus a fan-out edge per initial `Tee` branch — so validating
430    /// it means walking `edges` from `root` and carrying the flow along
431    /// each one, not folding a linear list.
432    pub contracts: HashMap<ElementId, PortContracts>,
433}
434
435impl BranchPlan {
436    /// Rejects any link in this branch whose two sides cannot meet, given
437    /// what is flowing into its root, and reports what each node was
438    /// resolved to emit so a later attach onto one of them can be checked
439    /// against the same answer.
440    ///
441    /// `incoming` is `None` while the branch is still detached — nothing is
442    /// known to be flowing yet, so only links downstream of a stage that
443    /// produces something of its own get checked. The same walk runs again
444    /// at attach time with the real upstream contract, which is what
445    /// catches a branch whose leading stages are all passthrough: those
446    /// carry the flow through untouched, so the requirement that matters
447    /// belongs to an element further down.
448    pub(crate) fn resolve(
449        &self,
450        incoming: Option<ResolvedFlow>,
451    ) -> Result<HashMap<ElementId, Option<ResolvedFlow>>, GraphError> {
452        let mut outgoing_by_node = HashMap::new();
453        let name_of = |id: ElementId| {
454            self.nodes
455                .iter()
456                .find(|node| node.id == id)
457                .map(|node| node.name.clone())
458                .unwrap_or_else(|| "<unknown>".into())
459        };
460
461        // A plan is a tree, so every node is reached exactly once; the
462        // visited set only keeps a malformed plan from looping forever.
463        let mut visited = HashSet::new();
464        let mut pending = vec![(self.root, incoming)];
465        while let Some((id, flow)) = pending.pop() {
466            if !visited.insert(id) {
467                continue;
468            }
469            let Some(contracts) = self.contracts.get(&id) else {
470                continue;
471            };
472
473            if let (Some(flow), InputContract::Fixed(accepted)) = (&flow, contracts.input)
474                && !accepted.accepts(&flow.contract)
475            {
476                return Err(GraphError::IncompatibleLink {
477                    producer: flow.producer.clone(),
478                    produced: flow.contract,
479                    consumer: name_of(id),
480                    accepted,
481                });
482            }
483
484            let outgoing = match contracts.output {
485                OutputContract::Fixed(contract) => Some(ResolvedFlow {
486                    producer: name_of(id),
487                    contract,
488                }),
489                OutputContract::Passthrough => flow,
490                OutputContract::Unknown => None,
491            };
492
493            // Fan-out: every branch of a `Tee` receives the same buffers,
494            // so each outgoing edge carries the same resolved flow.
495            outgoing_by_node.insert(id, outgoing.clone());
496            for edge in self.edges.iter().filter(|edge| edge.from.element == id) {
497                pending.push((edge.to.element, outgoing.clone()));
498            }
499        }
500        Ok(outgoing_by_node)
501    }
502}
503
504#[derive(Debug)]
505struct BranchRecord {
506    parent: ElementId,
507    owned_nodes: HashSet<ElementId>,
508}
509
510#[derive(Default)]
511struct GraphState {
512    next_element_id: u64,
513    next_edge_id: u64,
514    next_branch_id: u64,
515    revision: u64,
516    nodes: Vec<NodeInfo>,
517    edges: Vec<EdgeInfo>,
518    branches: HashMap<BranchId, BranchRecord>,
519    /// What each attached element was resolved to emit, recorded by the
520    /// attach that committed it. Internal bookkeeping, not part of
521    /// [`GraphSnapshot`]: its only reader is a later attach onto one of
522    /// these elements — a [`crate::elements::Tee`] gaining a branch while
523    /// the pipeline runs — which has no other way to know what is already
524    /// flowing through it.
525    outgoing: HashMap<ElementId, Option<ResolvedFlow>>,
526}
527
528/// Live, transactionally-updated graph behind [`crate::pipeline::Pipeline`].
529/// A snapshot never observes half of an attach/detach operation.
530#[derive(Clone, Default)]
531pub struct PipelineGraph(Arc<Mutex<GraphState>>);
532
533impl PipelineGraph {
534    /// Creates an empty graph with revision zero and fresh ID counters.
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Copies nodes, edges, and revision under the graph lock, then releases
540    /// the lock before returning.
541    pub fn snapshot(&self) -> GraphSnapshot {
542        let state = self.0.lock().unwrap();
543        GraphSnapshot {
544            revision: state.revision,
545            nodes: state.nodes.clone(),
546            edges: state.edges.clone(),
547        }
548    }
549
550    #[cfg(test)]
551    pub(crate) fn resolved_output_count(&self) -> usize {
552        self.0.lock().unwrap().outgoing.len()
553    }
554
555    /// Returns the attached branch that owns `element`, if the element was
556    /// introduced by a branch attachment transaction.
557    ///
558    /// Source nodes are created directly and therefore return `None`.
559    pub fn branch_containing(&self, element: ElementId) -> Option<BranchId> {
560        let state = self.0.lock().unwrap();
561        state
562            .branches
563            .iter()
564            .find_map(|(id, branch)| branch.owned_nodes.contains(&element).then_some(*id))
565    }
566
567    pub(crate) fn reserve_element_id(&self) -> ElementId {
568        let mut state = self.0.lock().unwrap();
569        state.next_element_id += 1;
570        ElementId(state.next_element_id)
571    }
572
573    pub(crate) fn add_source(&self, element_type: ElementType, name: Arc<str>) -> ElementId {
574        let id = self.reserve_element_id();
575        let mut state = self.0.lock().unwrap();
576        state.nodes.push(NodeInfo {
577            id,
578            element_type,
579            name,
580        });
581        state.revision += 1;
582        id
583    }
584
585    /// Validates a complete branch, performs its runtime mutation while the
586    /// graph is locked, then commits every node and edge as one revision.
587    pub(crate) fn attach_with(
588        &self,
589        parent: ElementId,
590        from_port: Arc<str>,
591        incoming: Incoming,
592        plan: BranchPlan,
593        attach_runtime: impl FnOnce(BranchId) -> Result<(), GraphError>,
594    ) -> Result<BranchId, GraphError> {
595        let mut state = self.0.lock().unwrap();
596        if !state.nodes.iter().any(|node| node.id == parent) {
597            return Err(GraphError::ParentNotAttached(parent));
598        }
599        if plan.nodes.is_empty() {
600            return Err(GraphError::EmptyBranch);
601        }
602        for node in &plan.nodes {
603            if state.nodes.iter().any(|current| current.id == node.id) {
604                return Err(GraphError::NodeAlreadyAttached(node.id));
605            }
606        }
607
608        // Everything below mutates; this is the last thing that can
609        // refuse, so a rejected branch leaves the graph untouched.
610        let incoming = match incoming {
611            Incoming::Known(flow) => flow,
612            Incoming::FromParent => state.outgoing.get(&parent).cloned().flatten(),
613        };
614        let outgoing = plan.resolve(incoming)?;
615
616        state.next_branch_id += 1;
617        let branch_id = BranchId(state.next_branch_id);
618        attach_runtime(branch_id)?;
619
620        let mut edges = Vec::with_capacity(plan.edges.len() + 1);
621        state.next_edge_id += 1;
622        edges.push(EdgeInfo {
623            id: EdgeId(state.next_edge_id),
624            branch_id,
625            from: PortRef {
626                element: parent,
627                port: from_port,
628            },
629            to: PortRef {
630                element: plan.root,
631                port: "sink".into(),
632            },
633        });
634        for edge in plan.edges {
635            state.next_edge_id += 1;
636            edges.push(EdgeInfo {
637                id: EdgeId(state.next_edge_id),
638                branch_id,
639                from: edge.from,
640                to: edge.to,
641            });
642        }
643
644        let owned_nodes = plan.nodes.iter().map(|node| node.id).collect();
645        state.outgoing.extend(outgoing);
646        state.nodes.extend(plan.nodes);
647        state.edges.extend(edges);
648        state.branches.insert(
649            branch_id,
650            BranchRecord {
651                parent,
652                owned_nodes,
653            },
654        );
655        state.revision += 1;
656        Ok(branch_id)
657    }
658
659    /// Performs runtime detach first, then removes this branch and any
660    /// branches attached below nodes it owned as one graph revision.
661    pub(crate) fn detach_with(
662        &self,
663        branch_id: BranchId,
664        detach_runtime: impl FnOnce() -> Result<(), GraphError>,
665    ) -> Result<(), GraphError> {
666        let mut state = self.0.lock().unwrap();
667        if !state.branches.contains_key(&branch_id) {
668            return Err(GraphError::BranchNotAttached(branch_id));
669        }
670        detach_runtime()?;
671
672        let mut removed_branches = HashSet::from([branch_id]);
673        let mut removed_nodes = HashSet::new();
674        loop {
675            for id in removed_branches.clone() {
676                if let Some(branch) = state.branches.get(&id) {
677                    removed_nodes.extend(branch.owned_nodes.iter().copied());
678                }
679            }
680            let before = removed_branches.len();
681            for (id, branch) in &state.branches {
682                if removed_nodes.contains(&branch.parent) {
683                    removed_branches.insert(*id);
684                }
685            }
686            if removed_branches.len() == before {
687                break;
688            }
689        }
690
691        state
692            .branches
693            .retain(|id, _| !removed_branches.contains(id));
694        state.nodes.retain(|node| !removed_nodes.contains(&node.id));
695        state
696            .edges
697            .retain(|edge| !removed_branches.contains(&edge.branch_id));
698        state
699            .outgoing
700            .retain(|element, _| !removed_nodes.contains(element));
701        state.revision += 1;
702        Ok(())
703    }
704}