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    /// Compiled at-rest encryption (#207), initialized on first use so
27    /// `new()` stays infallible. `Err` results surface as typed sink errors.
28    #[cfg(feature = "encryption")]
29    encryption: tokio::sync::OnceCell<Option<faucet_core::CompiledEncryption>>,
30    /// Mutex-protected writer for thread-safe concurrent writes.
31    writer: Mutex<Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
32    /// Tracks whether `ensure_open` has opened the file at least once.
33    /// On re-opens (after `flush()` clears the writer), we always use
34    /// append mode regardless of `config.append` so the new gzip / zstd
35    /// member appends instead of truncating the file. Without this, the
36    /// pipeline's per-bookmark flush would silently lose data when
37    /// `config.append = false` (the default).
38    opened_once: std::sync::atomic::AtomicBool,
39}
40
41impl JsonlSink {
42    /// Create a new JSON Lines sink. The file is opened on first write.
43    pub fn new(config: JsonlSinkConfig) -> Self {
44        Self {
45            config,
46            #[cfg(feature = "encryption")]
47            encryption: tokio::sync::OnceCell::new(),
48            writer: Mutex::new(None),
49            opened_once: std::sync::atomic::AtomicBool::new(false),
50        }
51    }
52
53    /// Compile (once) the configured at-rest encryption, validating the
54    /// compression conflict. `Ok(None)` when no `encryption:` block is set.
55    #[cfg(feature = "encryption")]
56    async fn compiled_encryption(
57        &self,
58    ) -> Result<&Option<faucet_core::CompiledEncryption>, FaucetError> {
59        self.encryption
60            .get_or_try_init(|| async {
61                let Some(spec) = &self.config.encryption else {
62                    return Ok(None);
63                };
64                #[cfg(feature = "compression")]
65                {
66                    let path_str = self.config.path.to_string_lossy();
67                    if self.config.compression.resolve(&path_str) != faucet_core::Compression::None
68                    {
69                        return Err(FaucetError::Config(
70                            "jsonl sink: `encryption` and `compression` are mutually \
71                             exclusive — per-line sealed records cannot form a valid \
72                             gzip/zstd stream"
73                                .into(),
74                        ));
75                    }
76                }
77                faucet_core::CompiledEncryption::compile(spec).map(Some)
78            })
79            .await
80    }
81
82    /// Ensure the file is open and return a mutable reference to the writer.
83    async fn ensure_open(
84        &self,
85    ) -> Result<
86        tokio::sync::MutexGuard<
87            '_,
88            Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>,
89        >,
90        FaucetError,
91    > {
92        let mut guard = self.writer.lock().await;
93        if guard.is_none() {
94            let opened_before = self.opened_once.load(std::sync::atomic::Ordering::Relaxed);
95            // First open obeys `config.append`. Re-opens (after flush()
96            // cleared the writer) always append, so flush-then-write
97            // sequences do not truncate previously-written data.
98            let (append, truncate) = if opened_before {
99                (true, false)
100            } else {
101                (self.config.append, !self.config.append)
102            };
103            if let Some(parent) = self.config.path.parent()
104                && !parent.as_os_str().is_empty()
105            {
106                tokio::fs::create_dir_all(parent).await.map_err(|e| {
107                    FaucetError::Sink(format!(
108                        "failed to create parent directory '{}': {e}",
109                        parent.display()
110                    ))
111                })?;
112            }
113            let file = OpenOptions::new()
114                .create(true)
115                .write(true)
116                .append(append)
117                .truncate(truncate)
118                .open(&self.config.path)
119                .await
120                .map_err(|e| {
121                    FaucetError::Sink(format!(
122                        "failed to open {}: {e}",
123                        self.config.path.display()
124                    ))
125                })?;
126            self.opened_once
127                .store(true, std::sync::atomic::Ordering::Relaxed);
128            let buffered = tokio::io::BufWriter::new(file);
129            #[cfg(feature = "compression")]
130            let writer: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>> = {
131                let path_str = self.config.path.to_string_lossy();
132                let codec = self.config.compression.resolve(&path_str);
133                faucet_core::compression::warn_mismatch(&path_str, codec);
134                faucet_core::compression::wrap_async_writer(buffered, codec)
135            };
136            #[cfg(not(feature = "compression"))]
137            let writer: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>> =
138                Box::pin(buffered);
139            *guard = Some(writer);
140        }
141        Ok(guard)
142    }
143}
144
145#[async_trait]
146impl faucet_core::Sink for JsonlSink {
147    fn connector_name(&self) -> &'static str {
148        "jsonl"
149    }
150
151    fn config_schema(&self) -> serde_json::Value {
152        serde_json::to_value(faucet_core::schema_for!(JsonlSinkConfig))
153            .expect("schema serialization")
154    }
155
156    fn dataset_uri(&self) -> String {
157        format!("file://{}", self.config.path.display())
158    }
159
160    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
161        if records.is_empty() {
162            return Ok(0);
163        }
164
165        // Validate/compile encryption before touching the file so a bad
166        // `encryption:` block never creates an empty output file.
167        #[cfg(feature = "encryption")]
168        let encryption = self.compiled_encryption().await?;
169
170        let mut guard = self.ensure_open().await?;
171        let writer = guard.as_mut().expect("writer opened in ensure_open");
172
173        for record in records {
174            let line = if self.config.pretty {
175                serde_json::to_string_pretty(record)
176            } else {
177                serde_json::to_string(record)
178            }
179            .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
180
181            #[cfg(feature = "encryption")]
182            let line = match encryption {
183                Some(enc) => {
184                    use base64::Engine as _;
185                    base64::engine::general_purpose::STANDARD.encode(enc.encrypt(line.as_bytes()))
186                }
187                None => line,
188            };
189
190            writer
191                .write_all(line.as_bytes())
192                .await
193                .map_err(|e| FaucetError::Sink(format!("write failed: {e}")))?;
194            writer
195                .write_all(b"\n")
196                .await
197                .map_err(|e| FaucetError::Sink(format!("write failed: {e}")))?;
198        }
199
200        tracing::debug!(records = records.len(), "JSONL batch written");
201        Ok(records.len())
202    }
203
204    async fn flush(&self) -> Result<(), FaucetError> {
205        let mut guard = self.writer.lock().await;
206        if let Some(mut writer) = guard.take() {
207            use tokio::io::AsyncWriteExt;
208            writer
209                .shutdown()
210                .await
211                .map_err(|e| FaucetError::Sink(format!("flush failed: {e}")))?;
212        }
213        Ok(())
214    }
215
216    /// Preflight probe for `faucet doctor`. Verifies the configured output
217    /// path's parent directory exists and is writable by creating, then
218    /// immediately removing, a uniquely-named temp file there. Never touches
219    /// the user's actual output file, so it is fully idempotent.
220    async fn check(
221        &self,
222        _ctx: &faucet_core::check::CheckContext,
223    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
224        use faucet_core::check::CheckReport;
225        let start = std::time::Instant::now();
226        let probe = crate::probe::probe_parent_writable(&self.config.path, start).await;
227        Ok(CheckReport::single(probe))
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use faucet_core::Sink;
235    use serde_json::json;
236    use tempfile::NamedTempFile;
237
238    #[test]
239    fn dataset_uri_uses_display_on_pathbuf() {
240        let sink = JsonlSink::new(JsonlSinkConfig::new("/tmp/output.jsonl"));
241        assert_eq!(sink.dataset_uri(), "file:///tmp/output.jsonl");
242    }
243
244    #[tokio::test]
245    async fn writes_jsonl_records() {
246        let tmp = NamedTempFile::new().unwrap();
247        let path = tmp.path().to_path_buf();
248        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
249
250        let records = vec![
251            json!({"id": 1, "name": "Alice"}),
252            json!({"id": 2, "name": "Bob"}),
253        ];
254        let count = sink.write_batch(&records).await.unwrap();
255        sink.flush().await.unwrap();
256
257        assert_eq!(count, 2);
258        let content = tokio::fs::read_to_string(&path).await.unwrap();
259        let lines: Vec<&str> = content.trim().split('\n').collect();
260        assert_eq!(lines.len(), 2);
261
262        let first: Value = serde_json::from_str(lines[0]).unwrap();
263        assert_eq!(first["id"], 1);
264    }
265
266    #[tokio::test]
267    async fn append_mode() {
268        let tmp = NamedTempFile::new().unwrap();
269        let path = tmp.path().to_path_buf();
270
271        // Write first batch.
272        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
273        sink.write_batch(&[json!({"id": 1})]).await.unwrap();
274        sink.flush().await.unwrap();
275        drop(sink);
276
277        // Write second batch in append mode.
278        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).append(true));
279        sink.write_batch(&[json!({"id": 2})]).await.unwrap();
280        sink.flush().await.unwrap();
281
282        let content = tokio::fs::read_to_string(&path).await.unwrap();
283        let lines: Vec<&str> = content.trim().split('\n').collect();
284        assert_eq!(lines.len(), 2);
285    }
286
287    #[tokio::test]
288    async fn empty_batch_returns_zero() {
289        let tmp = NamedTempFile::new().unwrap();
290        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
291        let count = sink.write_batch(&[]).await.unwrap();
292        assert_eq!(count, 0);
293    }
294
295    #[tokio::test]
296    async fn flush_without_write_is_noop() {
297        let tmp = NamedTempFile::new().unwrap();
298        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
299        assert!(sink.flush().await.is_ok());
300    }
301
302    #[tokio::test]
303    async fn multiple_batches_accumulate() {
304        let tmp = NamedTempFile::new().unwrap();
305        let path = tmp.path().to_path_buf();
306        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
307
308        sink.write_batch(&[json!({"a": 1})]).await.unwrap();
309        sink.write_batch(&[json!({"b": 2}), json!({"c": 3})])
310            .await
311            .unwrap();
312        sink.flush().await.unwrap();
313
314        let content = tokio::fs::read_to_string(&path).await.unwrap();
315        let lines: Vec<&str> = content.trim().split('\n').collect();
316        assert_eq!(lines.len(), 3);
317    }
318
319    #[tokio::test]
320    async fn jsonl_sink_connector_name_is_jsonl() {
321        use faucet_core::Sink;
322        let tmp = NamedTempFile::new().unwrap();
323        let sink = JsonlSink::new(JsonlSinkConfig::new(tmp.path()));
324        assert_eq!(sink.connector_name(), "jsonl");
325    }
326
327    #[tokio::test]
328    async fn check_passes_when_parent_dir_exists() {
329        let dir = tempfile::tempdir().unwrap();
330        let path = dir.path().join("out.jsonl");
331        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
332        let report = sink
333            .check(&faucet_core::check::CheckContext::default())
334            .await
335            .unwrap();
336        assert_eq!(report.failed_count(), 0);
337        assert_eq!(report.probes[0].name, "io");
338        // The probe must not have created the user's output file.
339        assert!(!path.exists(), "check() must not create the output file");
340    }
341
342    #[tokio::test]
343    async fn check_fails_when_parent_dir_missing() {
344        let dir = tempfile::tempdir().unwrap();
345        let path = dir.path().join("nope").join("out.jsonl");
346        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
347        let report = sink
348            .check(&faucet_core::check::CheckContext::default())
349            .await
350            .unwrap();
351        assert_eq!(report.failed_count(), 1);
352        assert_eq!(report.probes[0].name, "io");
353    }
354
355    #[tokio::test]
356    async fn creates_missing_parent_directories() {
357        let dir = tempfile::tempdir().unwrap();
358        let nested = dir.path().join("a").join("b").join("out.jsonl");
359        let sink = JsonlSink::new(JsonlSinkConfig::new(&nested));
360
361        let records = vec![json!({"id": 1})];
362        let count = sink.write_batch(&records).await.unwrap();
363        sink.flush().await.unwrap();
364
365        assert_eq!(count, 1);
366        assert!(nested.exists(), "output file must exist after write");
367        let content = tokio::fs::read_to_string(&nested).await.unwrap();
368        let first: Value = serde_json::from_str(content.trim()).unwrap();
369        assert_eq!(first["id"], 1);
370    }
371
372    #[cfg(feature = "compression")]
373    #[tokio::test]
374    async fn roundtrip_gzip() {
375        use faucet_core::CompressionConfig;
376        let tmp = NamedTempFile::with_suffix(".jsonl.gz").unwrap();
377        let path = tmp.path().to_path_buf();
378        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
379
380        let records = vec![
381            json!({"id": 1, "name": "Alice"}),
382            json!({"id": 2, "name": "Bob"}),
383        ];
384        sink.write_batch(&records).await.unwrap();
385        sink.flush().await.unwrap();
386
387        // Read raw bytes, decompress via faucet_core, parse JSONL.
388        let bytes = tokio::fs::read(&path).await.unwrap();
389        use tokio::io::AsyncReadExt;
390        let mut decoded = Vec::new();
391        let mut r = faucet_core::compression::wrap_async_reader(
392            tokio::io::BufReader::new(&bytes[..]),
393            faucet_core::Compression::Gzip,
394        );
395        r.read_to_end(&mut decoded).await.unwrap();
396        let text = String::from_utf8(decoded).unwrap();
397        let lines: Vec<&str> = text.trim().split('\n').collect();
398        assert_eq!(lines.len(), 2);
399        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
400        assert_eq!(first["id"], 1);
401    }
402
403    #[cfg(feature = "compression")]
404    #[tokio::test]
405    async fn roundtrip_zstd() {
406        use faucet_core::CompressionConfig;
407        let tmp = NamedTempFile::with_suffix(".jsonl.zst").unwrap();
408        let path = tmp.path().to_path_buf();
409        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
410        sink.write_batch(&[json!({"x": 42})]).await.unwrap();
411        sink.flush().await.unwrap();
412
413        let bytes = tokio::fs::read(&path).await.unwrap();
414        use tokio::io::AsyncReadExt;
415        let mut decoded = Vec::new();
416        let mut r = faucet_core::compression::wrap_async_reader(
417            tokio::io::BufReader::new(&bytes[..]),
418            faucet_core::Compression::Zstd,
419        );
420        r.read_to_end(&mut decoded).await.unwrap();
421        let text = String::from_utf8(decoded).unwrap();
422        let v: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
423        assert_eq!(v["x"], 42);
424    }
425
426    #[tokio::test]
427    async fn write_flush_write_does_not_truncate() {
428        // Regression: flush() clears the writer; the next write_batch
429        // must reopen in append mode regardless of config.append (which
430        // defaults to false). Without the opened_once guard, the second
431        // open would truncate and lose the first batch's records.
432        let tmp = NamedTempFile::new().unwrap();
433        let path = tmp.path().to_path_buf();
434        let sink = JsonlSink::new(JsonlSinkConfig::new(&path));
435
436        sink.write_batch(&[json!({"first": 1})]).await.unwrap();
437        sink.flush().await.unwrap();
438        sink.write_batch(&[json!({"second": 2})]).await.unwrap();
439        sink.flush().await.unwrap();
440
441        let content = tokio::fs::read_to_string(&path).await.unwrap();
442        let lines: Vec<&str> = content.trim().split('\n').collect();
443        assert_eq!(
444            lines.len(),
445            2,
446            "both batches must survive the mid-stream flush"
447        );
448        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
449        assert_eq!(first["first"], 1);
450        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
451        assert_eq!(second["second"], 2);
452    }
453
454    #[cfg(feature = "compression")]
455    #[tokio::test]
456    async fn write_flush_write_produces_multi_member_gzip() {
457        // With compression, flush() finalises one gzip member; the
458        // next write_batch starts a fresh member appended after it.
459        // The decoder reads both members back correctly.
460        use faucet_core::CompressionConfig;
461        let tmp = NamedTempFile::with_suffix(".jsonl.gz").unwrap();
462        let path = tmp.path().to_path_buf();
463        let sink = JsonlSink::new(JsonlSinkConfig::new(&path).compression(CompressionConfig::Auto));
464        sink.write_batch(&[json!({"first": 1})]).await.unwrap();
465        sink.flush().await.unwrap();
466        sink.write_batch(&[json!({"second": 2})]).await.unwrap();
467        sink.flush().await.unwrap();
468
469        let bytes = tokio::fs::read(&path).await.unwrap();
470        use tokio::io::AsyncReadExt;
471        let mut decoded = Vec::new();
472        let mut r = faucet_core::compression::wrap_async_reader(
473            tokio::io::BufReader::new(&bytes[..]),
474            faucet_core::Compression::Gzip,
475        );
476        r.read_to_end(&mut decoded).await.unwrap();
477        let text = String::from_utf8(decoded).unwrap();
478        let lines: Vec<&str> = text.trim().split('\n').collect();
479        assert_eq!(lines.len(), 2);
480    }
481
482    #[cfg(feature = "encryption")]
483    mod encryption_at_rest {
484        use super::*;
485        use base64::Engine as _;
486        use faucet_core::{EncryptionSpec, Sink};
487        use serde_json::json;
488        use tempfile::NamedTempFile;
489
490        fn spec(key: &str) -> EncryptionSpec {
491            EncryptionSpec {
492                key: key.into(),
493                previous_keys: vec![],
494                algorithm: Default::default(),
495            }
496        }
497
498        fn decrypt_lines(path: &std::path::Path, key: &str) -> Vec<Value> {
499            let enc = faucet_core::CompiledEncryption::compile(&spec(key)).unwrap();
500            std::fs::read_to_string(path)
501                .unwrap()
502                .lines()
503                .map(|line| {
504                    let sealed = base64::engine::general_purpose::STANDARD
505                        .decode(line)
506                        .expect("line is base64");
507                    let plain = enc.decrypt(&sealed).expect("line decrypts");
508                    serde_json::from_slice(&plain).expect("plaintext is JSON")
509                })
510                .collect()
511        }
512
513        #[tokio::test]
514        async fn per_line_encrypted_output_round_trips() {
515            let tmp = NamedTempFile::new().unwrap();
516            let path = tmp.path().to_path_buf();
517            let sink = JsonlSink::new(JsonlSinkConfig::new(&path).encryption(spec("dlq-key")));
518            let records = vec![json!({"id": 1, "pii": "s3cr3t"}), json!({"id": 2})];
519            sink.write_batch(&records).await.unwrap();
520            sink.flush().await.unwrap();
521
522            // Raw file: no plaintext leakage, every line base64.
523            let raw = std::fs::read_to_string(&path).unwrap();
524            assert!(!raw.contains("s3cr3t"));
525            assert_eq!(raw.trim().lines().count(), 2);
526
527            assert_eq!(decrypt_lines(&path, "dlq-key"), records);
528        }
529
530        #[tokio::test]
531        async fn append_across_flush_reopen_keeps_all_sealed_lines() {
532            let tmp = NamedTempFile::new().unwrap();
533            let path = tmp.path().to_path_buf();
534            let sink = JsonlSink::new(JsonlSinkConfig::new(&path).encryption(spec("k")));
535            sink.write_batch(&[json!({"first": 1})]).await.unwrap();
536            sink.flush().await.unwrap();
537            sink.write_batch(&[json!({"second": 2})]).await.unwrap();
538            sink.flush().await.unwrap();
539
540            let lines = decrypt_lines(&path, "k");
541            assert_eq!(lines, vec![json!({"first": 1}), json!({"second": 2})]);
542        }
543
544        #[tokio::test]
545        async fn empty_key_is_a_typed_error_before_any_file_io() {
546            let dir = tempfile::tempdir().unwrap();
547            let path = dir.path().join("out.jsonl");
548            let sink = JsonlSink::new(JsonlSinkConfig::new(&path).encryption(spec(" ")));
549            let err = sink.write_batch(&[json!(1)]).await.unwrap_err();
550            assert!(matches!(err, FaucetError::Config(_)));
551            assert!(!path.exists(), "a bad config must not create the file");
552        }
553
554        #[cfg(feature = "compression")]
555        #[tokio::test]
556        async fn compression_and_encryption_are_mutually_exclusive() {
557            let dir = tempfile::tempdir().unwrap();
558            let path = dir.path().join("out.jsonl.gz"); // Auto resolves gzip
559            let sink = JsonlSink::new(JsonlSinkConfig::new(&path).encryption(spec("k")));
560            let err = sink.write_batch(&[json!(1)]).await.unwrap_err();
561            assert!(err.to_string().contains("mutually"), "{err}");
562        }
563
564        #[cfg(feature = "compression")]
565        #[tokio::test]
566        async fn encryption_with_plain_path_and_auto_compression_is_fine() {
567            let dir = tempfile::tempdir().unwrap();
568            let path = dir.path().join("out.jsonl"); // Auto resolves None
569            let sink = JsonlSink::new(JsonlSinkConfig::new(&path).encryption(spec("k")));
570            sink.write_batch(&[json!({"ok": true})]).await.unwrap();
571            sink.flush().await.unwrap();
572            assert_eq!(decrypt_lines(&path, "k"), vec![json!({"ok": true})]);
573        }
574    }
575}