Skip to main content

faucet_sink_jsonl/
sink.rs

1//! JSON Lines file sink.
2
3use crate::config::JsonlSinkConfig;
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use serde_json::Value;
7use tokio::fs::OpenOptions;
8use tokio::io::AsyncWriteExt;
9use tokio::sync::Mutex;
10
11/// A sink that writes JSON records to a file in JSON Lines format.
12///
13/// Each record is written as a single line of JSON followed by a newline.
14/// The file is opened lazily on the first `write_batch` call.
15///
16/// With the `compression` feature, the writer transparently wraps the file
17/// with a gzip / zstd encoder based on the `compression` config field.
18/// [`Sink::flush`](faucet_core::Sink::flush) finalises the encoder (writes the trailer) and clears the
19/// writer slot — a subsequent `write_batch` reopens the file in append mode
20/// (independent of `config.append`) and starts a fresh encoder, producing a
21/// multi-member compressed file that decoders read back correctly. This makes
22/// the per-page `flush` the pipeline emits for bookmarked pages safe for CDC
23/// sources — every transaction appends rather than truncates.
24pub struct JsonlSink {
25    config: JsonlSinkConfig,
26    /// Mutex-protected writer for thread-safe concurrent writes.
27    writer: Mutex<Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
28    /// Tracks whether `ensure_open` has opened the file at least once.
29    /// On re-opens (after `flush()` clears the writer), we always use
30    /// append mode regardless of `config.append` so the new gzip / zstd
31    /// member appends instead of truncating the file. Without this, the
32    /// pipeline's per-bookmark flush would silently lose data when
33    /// `config.append = false` (the default).
34    opened_once: std::sync::atomic::AtomicBool,
35}
36
37impl JsonlSink {
38    /// Create a new JSON Lines sink. The file is opened on first write.
39    pub fn new(config: JsonlSinkConfig) -> Self {
40        Self {
41            config,
42            writer: Mutex::new(None),
43            opened_once: std::sync::atomic::AtomicBool::new(false),
44        }
45    }
46
47    /// Ensure the file is open and return a mutable reference to the writer.
48    async fn ensure_open(
49        &self,
50    ) -> Result<
51        tokio::sync::MutexGuard<
52            '_,
53            Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>,
54        >,
55        FaucetError,
56    > {
57        let mut guard = self.writer.lock().await;
58        if guard.is_none() {
59            let opened_before = self.opened_once.load(std::sync::atomic::Ordering::Relaxed);
60            // First open obeys `config.append`. Re-opens (after flush()
61            // cleared the writer) always append, so flush-then-write
62            // sequences do not truncate previously-written data.
63            let (append, truncate) = if opened_before {
64                (true, false)
65            } else {
66                (self.config.append, !self.config.append)
67            };
68            if let Some(parent) = self.config.path.parent()
69                && !parent.as_os_str().is_empty()
70            {
71                tokio::fs::create_dir_all(parent).await.map_err(|e| {
72                    FaucetError::Sink(format!(
73                        "failed to create parent directory '{}': {e}",
74                        parent.display()
75                    ))
76                })?;
77            }
78            let file = OpenOptions::new()
79                .create(true)
80                .write(true)
81                .append(append)
82                .truncate(truncate)
83                .open(&self.config.path)
84                .await
85                .map_err(|e| {
86                    FaucetError::Sink(format!(
87                        "failed to open {}: {e}",
88                        self.config.path.display()
89                    ))
90                })?;
91            self.opened_once
92                .store(true, std::sync::atomic::Ordering::Relaxed);
93            let buffered = tokio::io::BufWriter::new(file);
94            #[cfg(feature = "compression")]
95            let writer: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>> = {
96                let path_str = self.config.path.to_string_lossy();
97                let codec = self.config.compression.resolve(&path_str);
98                faucet_core::compression::warn_mismatch(&path_str, codec);
99                faucet_core::compression::wrap_async_writer(buffered, codec)
100            };
101            #[cfg(not(feature = "compression"))]
102            let writer: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>> =
103                Box::pin(buffered);
104            *guard = Some(writer);
105        }
106        Ok(guard)
107    }
108}
109
110#[async_trait]
111impl faucet_core::Sink for JsonlSink {
112    fn connector_name(&self) -> &'static str {
113        "jsonl"
114    }
115
116    fn config_schema(&self) -> serde_json::Value {
117        serde_json::to_value(faucet_core::schema_for!(JsonlSinkConfig))
118            .expect("schema serialization")
119    }
120
121    fn dataset_uri(&self) -> String {
122        format!("file://{}", self.config.path.display())
123    }
124
125    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
126        if records.is_empty() {
127            return Ok(0);
128        }
129
130        let mut guard = self.ensure_open().await?;
131        let writer = guard.as_mut().expect("writer opened in ensure_open");
132
133        for record in records {
134            let line = if self.config.pretty {
135                serde_json::to_string_pretty(record)
136            } else {
137                serde_json::to_string(record)
138            }
139            .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
140
141            writer
142                .write_all(line.as_bytes())
143                .await
144                .map_err(|e| FaucetError::Sink(format!("write failed: {e}")))?;
145            writer
146                .write_all(b"\n")
147                .await
148                .map_err(|e| FaucetError::Sink(format!("write failed: {e}")))?;
149        }
150
151        tracing::debug!(records = records.len(), "JSONL batch written");
152        Ok(records.len())
153    }
154
155    async fn flush(&self) -> Result<(), FaucetError> {
156        let mut guard = self.writer.lock().await;
157        if let Some(mut writer) = guard.take() {
158            use tokio::io::AsyncWriteExt;
159            writer
160                .shutdown()
161                .await
162                .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))?;
163        }
164        Ok(())
165    }
166
167    /// Preflight probe for `faucet doctor`. Verifies the configured output
168    /// path's parent directory exists and is writable by creating, then
169    /// immediately removing, a uniquely-named temp file there. Never touches
170    /// the user's actual output file, so it is fully idempotent.
171    async fn check(
172        &self,
173        _ctx: &faucet_core::check::CheckContext,
174    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
175        use faucet_core::check::CheckReport;
176        let start = std::time::Instant::now();
177        let probe = crate::probe::probe_parent_writable(&self.config.path, start).await;
178        Ok(CheckReport::single(probe))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use faucet_core::Sink;
186    use serde_json::json;
187    use tempfile::NamedTempFile;
188
189    #[test]
190    fn dataset_uri_uses_display_on_pathbuf() {
191        let sink = JsonlSink::new(JsonlSinkConfig::new("/tmp/output.jsonl"));
192        assert_eq!(sink.dataset_uri(), "file:///tmp/output.jsonl");
193    }
194
195    #[tokio::test]
196    async fn writes_jsonl_records() {
197        let tmp = NamedTempFile::new().unwrap();
198        let path = tmp.path().to_path_buf();
199        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
200
201        let records = vec![
202            json!({"id": 1, "name": "Alice"}),
203            json!({"id": 2, "name": "Bob"}),
204        ];
205        let count = sink.write_batch(&records).await.unwrap();
206        sink.flush().await.unwrap();
207
208        assert_eq!(count, 2);
209        let content = tokio::fs::read_to_string(&path).await.unwrap();
210        let lines: Vec<&str> = content.trim().split('\n').collect();
211        assert_eq!(lines.len(), 2);
212
213        let first: Value = serde_json::from_str(lines[0]).unwrap();
214        assert_eq!(first["id"], 1);
215    }
216
217    #[tokio::test]
218    async fn append_mode() {
219        let tmp = NamedTempFile::new().unwrap();
220        let path = tmp.path().to_path_buf();
221
222        // Write first batch.
223        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
224        sink.write_batch(&[json!({"id": 1})]).await.unwrap();
225        sink.flush().await.unwrap();
226        drop(sink);
227
228        // Write second batch in append mode.
229        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).append(true));
230        sink.write_batch(&[json!({"id": 2})]).await.unwrap();
231        sink.flush().await.unwrap();
232
233        let content = tokio::fs::read_to_string(&path).await.unwrap();
234        let lines: Vec<&str> = content.trim().split('\n').collect();
235        assert_eq!(lines.len(), 2);
236    }
237
238    #[tokio::test]
239    async fn empty_batch_returns_zero() {
240        let tmp = NamedTempFile::new().unwrap();
241        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
242        let count = sink.write_batch(&[]).await.unwrap();
243        assert_eq!(count, 0);
244    }
245
246    #[tokio::test]
247    async fn flush_without_write_is_noop() {
248        let tmp = NamedTempFile::new().unwrap();
249        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
250        assert!(sink.flush().await.is_ok());
251    }
252
253    #[tokio::test]
254    async fn multiple_batches_accumulate() {
255        let tmp = NamedTempFile::new().unwrap();
256        let path = tmp.path().to_path_buf();
257        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
258
259        sink.write_batch(&[json!({"a": 1})]).await.unwrap();
260        sink.write_batch(&[json!({"b": 2}), json!({"c": 3})])
261            .await
262            .unwrap();
263        sink.flush().await.unwrap();
264
265        let content = tokio::fs::read_to_string(&path).await.unwrap();
266        let lines: Vec<&str> = content.trim().split('\n').collect();
267        assert_eq!(lines.len(), 3);
268    }
269
270    #[tokio::test]
271    async fn jsonl_sink_connector_name_is_jsonl() {
272        use faucet_core::Sink;
273        let tmp = NamedTempFile::new().unwrap();
274        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
275        assert_eq!(sink.connector_name(), "jsonl");
276    }
277
278    #[tokio::test]
279    async fn check_passes_when_parent_dir_exists() {
280        let dir = tempfile::tempdir().unwrap();
281        let path = dir.path().join("out.jsonl");
282        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
283        let report = sink
284            .check(&faucet_core::check::CheckContext::default())
285            .await
286            .unwrap();
287        assert_eq!(report.failed_count(), 0);
288        assert_eq!(report.probes[0].name, "io");
289        // The probe must not have created the user's output file.
290        assert!(!path.exists(), "check() must not create the output file");
291    }
292
293    #[tokio::test]
294    async fn check_fails_when_parent_dir_missing() {
295        let dir = tempfile::tempdir().unwrap();
296        let path = dir.path().join("nope").join("out.jsonl");
297        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
298        let report = sink
299            .check(&faucet_core::check::CheckContext::default())
300            .await
301            .unwrap();
302        assert_eq!(report.failed_count(), 1);
303        assert_eq!(report.probes[0].name, "io");
304    }
305
306    #[tokio::test]
307    async fn creates_missing_parent_directories() {
308        let dir = tempfile::tempdir().unwrap();
309        let nested = dir.path().join("a").join("b").join("out.jsonl");
310        let sink = JsonlSink::new(JsonlSinkConfig::new(&nested));
311
312        let records = vec![json!({"id": 1})];
313        let count = sink.write_batch(&records).await.unwrap();
314        sink.flush().await.unwrap();
315
316        assert_eq!(count, 1);
317        assert!(nested.exists(), "output file must exist after write");
318        let content = tokio::fs::read_to_string(&nested).await.unwrap();
319        let first: Value = serde_json::from_str(content.trim()).unwrap();
320        assert_eq!(first["id"], 1);
321    }
322
323    #[cfg(feature = "compression")]
324    #[tokio::test]
325    async fn roundtrip_gzip() {
326        use faucet_core::CompressionConfig;
327        let tmp = NamedTempFile::with_suffix(".jsonl.gz").unwrap();
328        let path = tmp.path().to_path_buf();
329        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
330
331        let records = vec![
332            json!({"id": 1, "name": "Alice"}),
333            json!({"id": 2, "name": "Bob"}),
334        ];
335        sink.write_batch(&records).await.unwrap();
336        sink.flush().await.unwrap();
337
338        // Read raw bytes, decompress via faucet_core, parse JSONL.
339        let bytes = tokio::fs::read(&path).await.unwrap();
340        use tokio::io::AsyncReadExt;
341        let mut decoded = Vec::new();
342        let mut r = faucet_core::compression::wrap_async_reader(
343            tokio::io::BufReader::new(&bytes[..]),
344            faucet_core::Compression::Gzip,
345        );
346        r.read_to_end(&mut decoded).await.unwrap();
347        let text = String::from_utf8(decoded).unwrap();
348        let lines: Vec<&str> = text.trim().split('\n').collect();
349        assert_eq!(lines.len(), 2);
350        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
351        assert_eq!(first["id"], 1);
352    }
353
354    #[cfg(feature = "compression")]
355    #[tokio::test]
356    async fn roundtrip_zstd() {
357        use faucet_core::CompressionConfig;
358        let tmp = NamedTempFile::with_suffix(".jsonl.zst").unwrap();
359        let path = tmp.path().to_path_buf();
360        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
361        sink.write_batch(&[json!({"x": 42})]).await.unwrap();
362        sink.flush().await.unwrap();
363
364        let bytes = tokio::fs::read(&path).await.unwrap();
365        use tokio::io::AsyncReadExt;
366        let mut decoded = Vec::new();
367        let mut r = faucet_core::compression::wrap_async_reader(
368            tokio::io::BufReader::new(&bytes[..]),
369            faucet_core::Compression::Zstd,
370        );
371        r.read_to_end(&mut decoded).await.unwrap();
372        let text = String::from_utf8(decoded).unwrap();
373        let v: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
374        assert_eq!(v["x"], 42);
375    }
376
377    #[tokio::test]
378    async fn write_flush_write_does_not_truncate() {
379        // Regression: flush() clears the writer; the next write_batch
380        // must reopen in append mode regardless of config.append (which
381        // defaults to false). Without the opened_once guard, the second
382        // open would truncate and lose the first batch's records.
383        let tmp = NamedTempFile::new().unwrap();
384        let path = tmp.path().to_path_buf();
385        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
386
387        sink.write_batch(&[json!({"first": 1})]).await.unwrap();
388        sink.flush().await.unwrap();
389        sink.write_batch(&[json!({"second": 2})]).await.unwrap();
390        sink.flush().await.unwrap();
391
392        let content = tokio::fs::read_to_string(&path).await.unwrap();
393        let lines: Vec<&str> = content.trim().split('\n').collect();
394        assert_eq!(
395            lines.len(),
396            2,
397            "both batches must survive the mid-stream flush"
398        );
399        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
400        assert_eq!(first["first"], 1);
401        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
402        assert_eq!(second["second"], 2);
403    }
404
405    #[cfg(feature = "compression")]
406    #[tokio::test]
407    async fn write_flush_write_produces_multi_member_gzip() {
408        // With compression, flush() finalises one gzip member; the
409        // next write_batch starts a fresh member appended after it.
410        // The decoder reads both members back correctly.
411        use faucet_core::CompressionConfig;
412        let tmp = NamedTempFile::with_suffix(".jsonl.gz").unwrap();
413        let path = tmp.path().to_path_buf();
414        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
415        sink.write_batch(&[json!({"first": 1})]).await.unwrap();
416        sink.flush().await.unwrap();
417        sink.write_batch(&[json!({"second": 2})]).await.unwrap();
418        sink.flush().await.unwrap();
419
420        let bytes = tokio::fs::read(&path).await.unwrap();
421        use tokio::io::AsyncReadExt;
422        let mut decoded = Vec::new();
423        let mut r = faucet_core::compression::wrap_async_reader(
424            tokio::io::BufReader::new(&bytes[..]),
425            faucet_core::Compression::Gzip,
426        );
427        r.read_to_end(&mut decoded).await.unwrap();
428        let text = String::from_utf8(decoded).unwrap();
429        let lines: Vec<&str> = text.trim().split('\n').collect();
430        assert_eq!(lines.len(), 2);
431    }
432}