1use 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
11pub struct JsonlSink {
25 config: JsonlSinkConfig,
26 #[cfg(feature = "encryption")]
29 encryption: tokio::sync::OnceCell<Option<faucet_core::CompiledEncryption>>,
30 writer: Mutex<Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
32 opened_once: std::sync::atomic::AtomicBool,
39}
40
41impl JsonlSink {
42 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 #[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 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 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 #[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 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 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 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 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 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 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 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 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"); 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"); 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}