Skip to main content

faucet_source_singer/
stream.rs

1//! [`SingerSource`] — the `Source` implementation that bridges a Singer tap.
2//!
3//! This is the only place that drives the tap subprocess. Page assembly rules
4//! (v0, single-stream):
5//!
6//! - RECORD for the configured stream → buffered.
7//! - STATE → (if `flush_on_state`) flush the buffer as a page whose `bookmark`
8//!   is the STATE `value`; otherwise remember it as the pending checkpoint.
9//! - buffer reaches the `batch_size` hint → flush a page with `bookmark: None`
10//!   (no STATE covers these rows yet; they re-emit from the last checkpoint on
11//!   resume and an idempotent sink dedups them).
12//! - clean EOF → flush any trailing buffer + pending checkpoint.
13//! - non-zero exit / idle timeout / malformed-fail → terminate and error
14//!   **without** committing the trailing un-checkpointed buffer.
15
16use std::collections::HashMap;
17use std::pin::Pin;
18use std::sync::Mutex;
19use std::time::Duration;
20
21use faucet_core::{FaucetError, Source, StreamPage, Value, async_trait, schema_for};
22use futures::StreamExt;
23use futures_core::Stream;
24
25use crate::assemble::PageAssembler;
26use crate::config::{MalformedPolicy, SingerSourceConfig};
27use crate::message::SingerMessage;
28use crate::process::{Line, Recv, TapProcess};
29
30/// A source that runs a Singer tap and adapts its output into faucet records.
31///
32/// **Tier-2 / experimental.** This reintroduces a runtime (usually Python)
33/// dependency for pipelines that use it, throughput is Singer-class rather than
34/// faucet-class, and STATE-based resume granularity depends on the individual
35/// tap. See the crate README.
36pub struct SingerSource {
37    config: SingerSourceConfig,
38    /// Resume bookmark (the last persisted STATE `value`), injected by the
39    /// pipeline via [`apply_start_bookmark`](Source::apply_start_bookmark).
40    start_bookmark: Mutex<Option<Value>>,
41}
42
43impl SingerSource {
44    /// Build a source from its configuration.
45    pub fn new(config: SingerSourceConfig) -> Self {
46        Self {
47            config,
48            start_bookmark: Mutex::new(None),
49        }
50    }
51}
52
53#[async_trait]
54impl Source for SingerSource {
55    async fn fetch_with_context(
56        &self,
57        context: &HashMap<String, Value>,
58    ) -> Result<Vec<Value>, FaucetError> {
59        tracing::warn!(
60            "SingerSource::fetch_with_context buffers the entire stream in memory, \
61             defeating bounded-memory streaming; prefer driving stream_pages via the pipeline"
62        );
63        // batch_size 0 = single logical batch; we concatenate every page.
64        let mut stream = self.stream_pages(context, 0);
65        let mut all = Vec::new();
66        while let Some(page) = stream.next().await {
67            all.extend(page?.records);
68        }
69        Ok(all)
70    }
71
72    fn stream_pages<'a>(
73        &'a self,
74        _context: &'a HashMap<String, Value>,
75        batch_size: usize,
76    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
77        // Take the resume bookmark once at stream construction.
78        let start = self.start_bookmark.lock().unwrap().clone();
79        let idle = self.config.idle_timeout_secs.map(Duration::from_secs);
80        let target_stream = self.config.stream.clone();
81        let flush_on_state = self.config.flush_on_state;
82        let on_malformed = self.config.on_malformed;
83
84        Box::pin(async_stream::try_stream! {
85            let mut process = TapProcess::spawn(&self.config, start.as_ref())?;
86            let mut assembler = PageAssembler::new(target_stream, batch_size, flush_on_state);
87
88            loop {
89                match process.recv(idle).await {
90                    Recv::Line(Line::Parsed(msg)) => match msg {
91                        SingerMessage::Record { stream, record } => {
92                            if let Some(page) = assembler.on_record(&stream, record) {
93                                yield page;
94                            }
95                        }
96                        SingerMessage::State { value } => {
97                            if let Some(page) = assembler.on_state(value) {
98                                yield page;
99                            }
100                        }
101                        SingerMessage::Schema { stream, .. } => {
102                            // v0 pass-through: faucet's sinks infer schema from
103                            // records, so the SCHEMA declaration is logged and
104                            // skipped.
105                            // FUTURE (core RFC): a Source::record_schema() hook
106                            // would let us forward this to schema-aware sinks.
107                            tracing::debug!(stream = %stream, "singer SCHEMA (v0: pass-through)");
108                        }
109                        SingerMessage::Other { message_type } => {
110                            tracing::debug!(message_type = %message_type, "singer message ignored (v0)");
111                        }
112                    },
113                    Recv::Line(Line::Malformed(reason)) => match on_malformed {
114                        MalformedPolicy::Skip => {
115                            tracing::warn!(reason = %reason, "skipping malformed tap output line");
116                        }
117                        MalformedPolicy::Fail => {
118                            process.shutdown().await;
119                            Err(FaucetError::Source(format!(
120                                "malformed tap output line: {reason}"
121                            )))?;
122                        }
123                    },
124                    Recv::IdleTimeout => {
125                        let tail = process.stderr_tail();
126                        process.shutdown().await;
127                        Err(FaucetError::Source(format!(
128                            "tap produced no output within idle timeout; last stderr:\n{tail}"
129                        )))?;
130                    }
131                    Recv::Eof => {
132                        // Determine exit status BEFORE committing the trailing
133                        // buffer, so a failed run never writes an
134                        // un-checkpointed final page.
135                        process.shutdown_and_check().await?;
136                        if let Some(page) = assembler.on_eof() {
137                            yield page;
138                        }
139                        break;
140                    }
141                }
142            }
143        })
144    }
145
146    fn config_schema(&self) -> Value {
147        serde_json::to_value(schema_for!(SingerSourceConfig)).unwrap_or(Value::Null)
148    }
149
150    fn connector_name(&self) -> &'static str {
151        "singer"
152    }
153
154    fn state_key(&self) -> Option<String> {
155        Some(self.config.effective_state_key())
156    }
157
158    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
159        *self.start_bookmark.lock().unwrap() = Some(bookmark);
160        Ok(())
161    }
162
163    /// Non-spawning preflight for `faucet doctor`: verifies the tap executable
164    /// resolves (on `PATH` or as a file) and, when a catalog is configured, that
165    /// the selected `stream` exists in it. We deliberately do **not** run the
166    /// tap here — that would have side effects and could block.
167    async fn check(
168        &self,
169        _ctx: &faucet_core::check::CheckContext,
170    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
171        use faucet_core::check::{CheckReport, Probe};
172        use std::time::Instant;
173
174        let mut probes = Vec::new();
175
176        let start = Instant::now();
177        if resolve_executable(&self.config.executable).is_some() {
178            probes.push(Probe::pass("tap_executable", start.elapsed()));
179        } else {
180            probes.push(Probe::fail_hint(
181                "tap_executable",
182                start.elapsed(),
183                format!(
184                    "tap '{}' not found on PATH or as a file",
185                    self.config.executable
186                ),
187                "install the tap (e.g. `pipx install tap-foo`) and ensure it is on PATH, \
188                 or set `executable` to an absolute path",
189            ));
190        }
191
192        let start = Instant::now();
193        match &self.config.catalog {
194            Some(catalog) => {
195                if catalog_has_stream(catalog, &self.config.stream) {
196                    probes.push(Probe::pass("stream_in_catalog", start.elapsed()));
197                } else {
198                    probes.push(Probe::fail_hint(
199                        "stream_in_catalog",
200                        start.elapsed(),
201                        format!(
202                            "stream '{}' is not present in the configured catalog",
203                            self.config.stream
204                        ),
205                        "check the `stream` name against the catalog's stream list",
206                    ));
207                }
208            }
209            None => probes.push(Probe::skip(
210                "stream_in_catalog",
211                "no catalog configured; run `faucet init --source singer --discover` to generate one",
212            )),
213        }
214
215        Ok(CheckReport { probes })
216    }
217}
218
219/// Resolve a tap executable to a file path: an absolute/relative path is checked
220/// directly, a bare name is searched on `PATH`. Returns `None` if not found.
221fn resolve_executable(exe: &str) -> Option<std::path::PathBuf> {
222    use std::path::Path;
223    let p = Path::new(exe);
224    if exe.contains('/') || p.is_absolute() {
225        return if p.is_file() {
226            Some(p.to_path_buf())
227        } else {
228            None
229        };
230    }
231    let paths = std::env::var_os("PATH")?;
232    std::env::split_paths(&paths)
233        .map(|dir| dir.join(exe))
234        .find(|candidate| candidate.is_file())
235}
236
237/// Whether a Singer catalog lists `stream` (by `stream` or `tap_stream_id`).
238fn catalog_has_stream(catalog: &Value, stream: &str) -> bool {
239    catalog
240        .get("streams")
241        .and_then(Value::as_array)
242        .is_some_and(|streams| {
243            streams.iter().any(|s| {
244                s.get("stream").and_then(Value::as_str) == Some(stream)
245                    || s.get("tap_stream_id").and_then(Value::as_str) == Some(stream)
246            })
247        })
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    #[test]
256    fn catalog_has_stream_matches_stream_or_tap_stream_id() {
257        let catalog = json!({
258            "streams": [
259                { "tap_stream_id": "users", "stream": "users" },
260                { "tap_stream_id": "orders-v2", "stream": "orders" }
261            ]
262        });
263        assert!(catalog_has_stream(&catalog, "users"));
264        assert!(catalog_has_stream(&catalog, "orders")); // by `stream`
265        assert!(catalog_has_stream(&catalog, "orders-v2")); // by `tap_stream_id`
266        assert!(!catalog_has_stream(&catalog, "missing"));
267        assert!(!catalog_has_stream(&json!({}), "users"));
268    }
269
270    #[test]
271    fn resolve_executable_finds_absolute_file_and_rejects_missing() {
272        // A file that definitely exists.
273        assert!(
274            resolve_executable("/bin/sh").is_some() || resolve_executable("/bin/cat").is_some()
275        );
276        assert!(resolve_executable("/definitely/not/a/real/tap-xyz").is_none());
277        // A bare name that will not be on PATH.
278        assert!(resolve_executable("tap-this-does-not-exist-42").is_none());
279    }
280}