Skip to main content

faucet_cli/
topology.rs

1//! Topology mode (issues #71 / #72): build and run a
2//! [`faucet_core::Topology`] from a config's `pipeline.nodes` / `edges` block.
3//!
4//! When `pipeline.nodes` is non-empty the pipeline runs as an explicit node
5//! graph rather than a matrix. This module resolves each node's connector
6//! templates, compiles its transforms, wires the edges, and drives the core
7//! topology executor — reusing [`crate::registry`] for connector construction
8//! and [`crate::state`] / [`crate::executor::build_dlq_config`] for the
9//! sink-side plumbing.
10
11use crate::auth_catalog::AuthCatalog;
12use crate::config::{ConnectorSpec, NodeSpec, PipelineConfig};
13use crate::error::{CliError, CliResult};
14use crate::executor::{InvocationOutcome, RunSummary};
15use crate::merge::merge_value;
16use crate::registry::{build_sink, build_source};
17use crate::transforms::compile_transforms;
18use faucet_core::stage::compile_stage;
19use faucet_core::topology::{
20    JoinConfig, JoinNode, NodeKind, Topology, TopologyOnError, TopologyOptions,
21};
22use serde_json::Value;
23use std::collections::HashMap;
24use tokio_util::sync::CancellationToken;
25
26/// Whether the config selects topology mode (a non-empty `pipeline.nodes`).
27pub fn is_topology(cfg: &PipelineConfig) -> bool {
28    !cfg.pipeline.nodes.is_empty()
29}
30
31/// Resolve one source/sink node's template `ref` + inline overrides into a
32/// concrete `(kind, config)` pair.
33fn resolve_connector(
34    templates: &HashMap<String, ConnectorSpec>,
35    legacy: &Option<ConnectorSpec>,
36    template_ref: Option<&str>,
37    kind_override: Option<&str>,
38    config_override: Option<&Value>,
39    node_id: &str,
40    kind_label: &'static str,
41) -> CliResult<(String, Value)> {
42    let name = template_ref.unwrap_or("default");
43    let base: ConnectorSpec = if name == "default" {
44        templates
45            .get("default")
46            .cloned()
47            .or_else(|| legacy.clone())
48            .ok_or(CliError::MissingTemplate {
49                kind: kind_label,
50                row_id: node_id.to_string(),
51            })?
52    } else {
53        templates
54            .get(name)
55            .cloned()
56            .ok_or_else(|| CliError::UnknownTemplate {
57                kind: kind_label,
58                name: name.to_string(),
59                row_id: node_id.to_string(),
60                known: {
61                    let mut k: Vec<String> = templates.keys().cloned().collect();
62                    if legacy.is_some() {
63                        k.push("default".to_string());
64                    }
65                    k.sort();
66                    k
67                },
68            })?
69    };
70    let mut kind = base.kind;
71    let mut config = base.config;
72    if let Some(k) = kind_override {
73        kind = k.to_string();
74    }
75    if let Some(c) = config_override {
76        merge_value(&mut config, c.clone());
77    }
78    Ok((kind, config))
79}
80
81/// Build a [`faucet_core::Topology`] from the config's `pipeline.nodes` /
82/// `edges` block.
83pub async fn build_topology(cfg: &PipelineConfig, auth: &AuthCatalog) -> CliResult<Topology> {
84    if !cfg.matrix.is_empty() {
85        return Err(CliError::MatrixAndNodesBothPresent);
86    }
87
88    let spec = &cfg.pipeline;
89    let mut builder = Topology::builder();
90
91    // Deterministic node order (sorted by id) so errors/logs are stable.
92    let mut node_ids: Vec<&String> = spec.nodes.keys().collect();
93    node_ids.sort();
94
95    for id in &node_ids {
96        let node = &spec.nodes[*id];
97        let kind: NodeKind = match node {
98            NodeSpec::Source {
99                template,
100                kind,
101                config,
102            } => {
103                let (k, c) = resolve_connector(
104                    &spec.sources,
105                    &spec.source,
106                    template.as_deref(),
107                    kind.as_deref(),
108                    config.as_ref(),
109                    id,
110                    "source",
111                )?;
112                NodeKind::Source(build_source(&k, c, auth, None).await?)
113            }
114            NodeSpec::Sink {
115                template,
116                kind,
117                config,
118            } => {
119                let (k, c) = resolve_connector(
120                    &spec.sinks,
121                    &spec.sink,
122                    template.as_deref(),
123                    kind.as_deref(),
124                    config.as_ref(),
125                    id,
126                    "sink",
127                )?;
128                NodeKind::Sink(build_sink(&k, c, auth).await?)
129            }
130            NodeSpec::Transform { transforms } => {
131                let stages = compile_transforms(transforms)?;
132                let compiled = stages
133                    .iter()
134                    .map(compile_stage)
135                    .collect::<Result<Vec<_>, _>>()?;
136                NodeKind::Transform(compiled)
137            }
138            NodeSpec::Tee {
139                channel_capacity,
140                fanout,
141            } => NodeKind::Tee {
142                capacity: *channel_capacity,
143                fanout: *fanout,
144            },
145            NodeSpec::Merge => NodeKind::Merge,
146            NodeSpec::Join(js) => NodeKind::Join(JoinNode {
147                config: JoinConfig {
148                    mode: js.mode,
149                    build_key: js.build.key.clone(),
150                    probe_key: js.probe.key.clone(),
151                    projections: js.project.clone(),
152                    on_missing: js.on_missing.clone(),
153                    on_duplicate: js.on_duplicate,
154                    on_collision: js.on_collision,
155                    key_normalize: js.key_normalize,
156                    max_build_records: js.max_build_records,
157                },
158                build_edge: js.build.edge.clone(),
159                probe_edge: js.probe.edge.clone(),
160            }),
161        };
162        builder = builder.node((*id).clone(), kind);
163    }
164
165    // Validate edge endpoints up front for a friendly error, then wire them.
166    let known: Vec<String> = node_ids.iter().map(|s| (*s).clone()).collect();
167    for e in &spec.edges {
168        if !spec.nodes.contains_key(&e.from) {
169            return Err(CliError::EdgeEndpointMissing {
170                name: e.from.clone(),
171                known: known.clone(),
172            });
173        }
174        if !spec.nodes.contains_key(&e.to) {
175            return Err(CliError::EdgeEndpointMissing {
176                name: e.to.clone(),
177                known: known.clone(),
178            });
179        }
180        builder = match &e.label {
181            Some(label) => builder.labelled_edge(e.from.clone(), e.to.clone(), label.clone()),
182            None => builder.edge(e.from.clone(), e.to.clone()),
183        };
184    }
185
186    builder.build().map_err(|e| CliError::InvalidTopology {
187        message: e.to_string(),
188    })
189}
190
191/// Collect a bounded preview of each `source` node's records (source side
192/// only; downstream nodes are not run). Returns `(node_id, records)` per
193/// source node, in sorted node-id order.
194pub async fn preview_records(
195    cfg: &PipelineConfig,
196    auth: &AuthCatalog,
197    limit: usize,
198) -> CliResult<Vec<(String, Vec<Value>)>> {
199    if !cfg.matrix.is_empty() {
200        return Err(CliError::MatrixAndNodesBothPresent);
201    }
202    let spec = &cfg.pipeline;
203    let mut ids: Vec<&String> = spec.nodes.keys().collect();
204    ids.sort();
205
206    let mut out = Vec::new();
207    for id in ids {
208        if let NodeSpec::Source {
209            template,
210            kind,
211            config,
212        } = &spec.nodes[id]
213        {
214            let (k, c) = resolve_connector(
215                &spec.sources,
216                &spec.source,
217                template.as_deref(),
218                kind.as_deref(),
219                config.as_ref(),
220                id,
221                "source",
222            )?;
223            let source = build_source(&k, c, auth, None).await?;
224            let records = source.fetch_all().await?;
225            out.push((
226                id.clone(),
227                records.into_iter().take(limit).collect::<Vec<_>>(),
228            ));
229        }
230    }
231    if out.is_empty() {
232        return Err(CliError::InvalidTopology {
233            message: "no source nodes to preview".to_string(),
234        });
235    }
236    Ok(out)
237}
238
239/// Preview topology mode: build each `source` node and print the first
240/// `limit` records per source to stdout as JSON Lines.
241pub async fn preview(cfg: &PipelineConfig, auth: &AuthCatalog, limit: usize) -> CliResult<()> {
242    for (id, records) in preview_records(cfg, auth, limit).await? {
243        tracing::info!(node = %id, "previewing source node");
244        for rec in records {
245            println!("{}", serde_json::to_string(&rec).unwrap_or_default());
246        }
247    }
248    Ok(())
249}
250
251/// Preview topology sources into a JSON string (for the MCP `preview` tool).
252pub async fn preview_to_string(
253    cfg: &PipelineConfig,
254    auth: &AuthCatalog,
255    limit: usize,
256) -> CliResult<String> {
257    let sources = preview_records(cfg, auth, limit).await?;
258    let doc: Vec<Value> = sources
259        .into_iter()
260        .map(|(id, records)| {
261            serde_json::json!({ "node": id, "count": records.len(), "records": records })
262        })
263        .collect();
264    Ok(
265        serde_json::to_string_pretty(&serde_json::json!({ "sources": doc }))
266            .unwrap_or_else(|_| "[]".to_string()),
267    )
268}
269
270/// Build and run the topology, returning a [`RunSummary`] shaped like a matrix
271/// run (one invocation per sink node, plus one per node failure under
272/// `on_error: continue`).
273pub async fn run_topology(
274    cfg: &PipelineConfig,
275    auth: &AuthCatalog,
276    cancel: Option<CancellationToken>,
277) -> CliResult<RunSummary> {
278    let topo = build_topology(cfg, auth).await?;
279
280    let pipeline_name = cfg.name.clone().unwrap_or_else(|| "unnamed".to_string());
281    let run_id = uuid::Uuid::now_v7().to_string();
282
283    let on_error = match cfg.execution.as_ref().map(|e| e.on_error) {
284        Some(crate::config::OnError::Stop) => TopologyOnError::Propagate,
285        _ => TopologyOnError::Continue,
286    };
287
288    let mut opts = TopologyOptions::new(pipeline_name).with_on_error(on_error);
289    opts.run_id = run_id;
290
291    if let Some(state) = &cfg.pipeline.state {
292        opts = opts.with_state_store(crate::state::build_state_store(state).await?);
293    }
294    if let Some(dlq) = &cfg.pipeline.dlq {
295        opts = opts.with_dlq(crate::executor::build_dlq_config(dlq).await?);
296    }
297    if let Some(c) = cancel {
298        opts = opts.with_cancel(c);
299    }
300
301    let result = topo.run(opts).await?;
302
303    let mut invocations: Vec<InvocationOutcome> = result
304        .per_sink
305        .into_iter()
306        .map(|(node_id, records)| InvocationOutcome {
307            row_id: node_id,
308            parent_record_key: None,
309            records_written: records,
310            error: None,
311            metrics: None,
312        })
313        .collect();
314    invocations.sort_by(|a, b| a.row_id.cmp(&b.row_id));
315
316    for msg in result.errors {
317        invocations.push(InvocationOutcome {
318            row_id: "topology".to_string(),
319            parent_record_key: None,
320            records_written: 0,
321            error: Some(msg),
322            metrics: None,
323        });
324    }
325
326    Ok(RunSummary { invocations })
327}