faucet_source_singer/
stream.rs1use 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
30pub struct SingerSource {
37 config: SingerSourceConfig,
38 start_bookmark: Mutex<Option<Value>>,
41}
42
43impl SingerSource {
44 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 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 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 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 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 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
219fn 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
237fn 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")); assert!(catalog_has_stream(&catalog, "orders-v2")); 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 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 assert!(resolve_executable("tap-this-does-not-exist-42").is_none());
279 }
280}