faucet_sink_jsonl/
sink.rs1use 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 writer: Mutex<Option<std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send + Unpin>>>>,
28 opened_once: std::sync::atomic::AtomicBool,
35}
36
37impl JsonlSink {
38 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 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 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 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 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 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 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 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 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 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}