1use std::io::{BufWriter, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Mutex};
13use std::time::Duration;
14
15use arrow_array::RecordBatch;
16use arrow_schema::SchemaRef;
17use async_trait::async_trait;
18use laminar_core::durable_fs::{durable_rename, DurableRenameMode};
19use tracing::{debug, info};
20
21use crate::config::ConnectorConfig;
22use crate::connector::{
23 ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
24 SinkInputMode, SinkTopology, WriteResult,
25};
26use crate::error::ConnectorError;
27use crate::schema::traits::FormatEncoder;
28
29use super::config::{FileFormat, FileSinkConfig};
30
31enum FileBlockingTaskError {
32 Retired,
33 Worker(tokio::task::JoinError),
34}
35
36fn blocking_task_error(operation: &'static str, error: FileBlockingTaskError) -> ConnectorError {
37 match error {
38 FileBlockingTaskError::Retired => ConnectorError::InvalidState {
39 expected: "active file connector generation".into(),
40 actual: "retired".into(),
41 },
42 FileBlockingTaskError::Worker(error) => {
43 ConnectorError::Internal(format!("{operation}: {error}"))
44 }
45 }
46}
47
48fn ambiguous_blocking_task_error(
49 operation: &'static str,
50 error: FileBlockingTaskError,
51) -> ConnectorError {
52 match error {
53 FileBlockingTaskError::Retired => ConnectorError::InvalidState {
54 expected: "active file connector generation".into(),
55 actual: "retired".into(),
56 },
57 FileBlockingTaskError::Worker(error) => ConnectorError::outcome_unknown(
58 format!("{operation}: the worker ended after filesystem dispatch: {error}"),
59 error.is_cancelled(),
60 ),
61 }
62}
63
64enum FilePublicationError {
65 BeforeDispatch(ConnectorError),
66 AfterDispatch {
67 published: usize,
68 error: ConnectorError,
69 },
70}
71
72pub struct FileSink {
74 config: Option<FileSinkConfig>,
76 schema: SchemaRef,
78 encoder: Option<Box<dyn FormatEncoder>>,
80 next_generation: u64,
82 buffered_batches: Vec<RecordBatch>,
84 current_segment: usize,
86 segment_bytes: u64,
88 writer: Option<Arc<Mutex<BufWriter<std::fs::File>>>>,
90 active_tmp_files: Vec<PathBuf>,
92 is_open: bool,
94 retired: Arc<AtomicBool>,
96 task_owner: ConnectorTaskOwner,
98 task_tracker: ConnectorTaskTracker,
100}
101
102impl FileSink {
103 #[must_use]
105 pub fn new() -> Self {
106 Self::with_registry(None)
107 }
108
109 #[must_use]
111 pub fn with_registry(_registry: Option<&prometheus::Registry>) -> Self {
112 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
113 Self {
114 config: None,
115 schema: Arc::new(arrow_schema::Schema::empty()),
116 encoder: None,
117 next_generation: 1,
118 buffered_batches: Vec::new(),
119 current_segment: 0,
120 segment_bytes: 0,
121 writer: None,
122 active_tmp_files: Vec::new(),
123 is_open: false,
124 retired: Arc::new(AtomicBool::new(false)),
125 task_owner,
126 task_tracker,
127 }
128 }
129
130 async fn run_blocking<T, F>(&self, operation: F) -> Result<T, FileBlockingTaskError>
131 where
132 T: Send + 'static,
133 F: FnOnce() -> T + Send + 'static,
134 {
135 if self.retired.load(Ordering::Acquire) {
136 return Err(FileBlockingTaskError::Retired);
137 }
138 let guard = self
139 .task_owner
140 .track()
141 .expect("live file sink cannot have a retired task owner");
142 let retired = Arc::clone(&self.retired);
143 tokio::task::spawn_blocking(move || {
144 let _guard = guard;
145 if retired.load(Ordering::Acquire) {
146 Err(FileBlockingTaskError::Retired)
147 } else {
148 Ok(operation())
149 }
150 })
151 .await
152 .map_err(FileBlockingTaskError::Worker)?
153 }
154
155 fn ensure_open(&self) -> Result<(), ConnectorError> {
156 if self.is_open {
157 Ok(())
158 } else {
159 Err(ConnectorError::InvalidState {
160 expected: "open".into(),
161 actual: "closed".into(),
162 })
163 }
164 }
165
166 async fn open_segment_async(&mut self) -> Result<(), ConnectorError> {
169 self.ensure_open()?;
170 let config = self
171 .config
172 .as_ref()
173 .ok_or_else(|| ConnectorError::InvalidState {
174 expected: "configured".into(),
175 actual: "unconfigured".into(),
176 })?;
177 let filename = format!(
178 "{}_{:06}_{:03}.{}.tmp",
179 config.prefix,
180 self.next_generation,
181 self.current_segment,
182 config.format.extension()
183 );
184 let path = Path::new(&config.path).join(filename);
185 let open_path = path.clone();
186 self.active_tmp_files.push(path.clone());
189 let file = match self
190 .run_blocking(move || {
191 std::fs::OpenOptions::new()
192 .create_new(true)
193 .write(true)
194 .open(&open_path)
195 .map_err(|e| {
196 ConnectorError::WriteError(format!(
197 "cannot create temporary output '{}': {e}",
198 open_path.display()
199 ))
200 })
201 })
202 .await
203 .map_err(|error| blocking_task_error("temporary-file open failed", error))?
204 {
205 Ok(file) => file,
206 Err(error) => {
207 self.active_tmp_files.pop();
208 return Err(error);
209 }
210 };
211
212 self.writer = Some(Arc::new(Mutex::new(BufWriter::new(file))));
213 self.segment_bytes = 0;
214 Ok(())
215 }
216
217 async fn ensure_writer_async(&mut self) -> Result<(), ConnectorError> {
219 if self.writer.is_none() {
220 self.open_segment_async().await?;
221 }
222 Ok(())
223 }
224
225 async fn close_writer_async(&mut self) -> Result<(), ConnectorError> {
227 let Some(writer) = self.writer.clone() else {
228 return Ok(());
229 };
230 self.run_blocking(move || -> Result<(), ConnectorError> {
231 let mut w = writer
232 .lock()
233 .map_err(|_| ConnectorError::WriteError("file writer lock was poisoned".into()))?;
234 w.flush()
235 .map_err(|e| ConnectorError::WriteError(format!("flush error: {e}")))?;
236 w.get_ref()
237 .sync_all()
238 .map_err(|e| ConnectorError::WriteError(format!("file sync error: {e}")))
239 })
240 .await
241 .map_err(|error| ambiguous_blocking_task_error("writer flush failed", error))?
242 .map_err(|error| {
243 let retryable = error.is_transient();
244 ConnectorError::outcome_unknown(
245 format!("temporary-file flush or sync may have partially completed: {error}"),
246 retryable,
247 )
248 })?;
249 self.writer = None;
250 Ok(())
251 }
252
253 fn advance_segment(&mut self) -> Result<(), ConnectorError> {
254 self.current_segment = self.current_segment.checked_add(1).ok_or_else(|| {
255 ConnectorError::outcome_unknown(
256 "file data was staged but the segment counter is exhausted",
257 false,
258 )
259 })?;
260 self.segment_bytes = 0;
261 Ok(())
262 }
263
264 fn finish_published_generation(&mut self) -> Result<(), ConnectorError> {
265 self.next_generation = self.next_generation.checked_add(1).ok_or_else(|| {
266 ConnectorError::outcome_unknown(
267 "files were published but the generation counter is exhausted",
268 false,
269 )
270 })?;
271 self.current_segment = 0;
272 self.segment_bytes = 0;
273 Ok(())
274 }
275
276 async fn materialize_bulk_batches(&mut self) -> Result<(), ConnectorError> {
279 self.ensure_open()?;
280 let is_bulk = self
281 .config
282 .as_ref()
283 .is_some_and(|config| config.format.is_bulk_format());
284 if !is_bulk || self.buffered_batches.is_empty() {
285 return Ok(());
286 }
287
288 let encoder = self
289 .encoder
290 .take()
291 .ok_or_else(|| ConnectorError::InvalidState {
292 expected: "encoder ready".into(),
293 actual: "no encoder".into(),
294 })?;
295 let batches = self.buffered_batches.clone();
296 let schema = self.schema.clone();
297 let (encoder, encoded) = self
298 .run_blocking(move || {
299 let combined = if batches.len() == 1 {
300 Ok(batches[0].clone())
301 } else {
302 arrow_select::concat::concat_batches(&schema, &batches)
303 .map_err(|e| ConnectorError::WriteError(format!("batch concat error: {e}")))
304 };
305 let encoded = combined.and_then(|combined| {
306 encoder
307 .encode_batch(&combined)
308 .map_err(|e| ConnectorError::WriteError(format!("bulk encode error: {e}")))
309 });
310 (encoder, encoded)
311 })
312 .await
313 .map_err(|error| blocking_task_error("bulk encoder worker failed", error))?;
314 self.encoder = Some(encoder);
315 let mut encoded = encoded?;
316 if encoded.len() != 1 {
317 return Err(ConnectorError::WriteError(format!(
318 "bulk file encoder returned {} blobs; expected exactly one self-contained file",
319 encoded.len()
320 )));
321 }
322
323 let file_bytes = encoded.pop().expect("length checked above");
324 self.open_segment_async().await?;
325 let writer = self
326 .writer
327 .as_ref()
328 .expect("open_segment_async just ran")
329 .clone();
330 self.run_blocking(move || -> Result<(), ConnectorError> {
331 let mut writer = writer
332 .lock()
333 .map_err(|_| ConnectorError::WriteError("file writer lock was poisoned".into()))?;
334 writer
335 .write_all(&file_bytes)
336 .map_err(|e| ConnectorError::WriteError(format!("write error: {e}")))?;
337 Ok(())
338 })
339 .await
340 .map_err(|error| ambiguous_blocking_task_error("bulk file write failed", error))?
341 .map_err(|error| {
342 let retryable = error.is_transient();
343 ConnectorError::outcome_unknown(
344 format!("bulk temporary-file write may have partially completed: {error}"),
345 retryable,
346 )
347 })?;
348 self.buffered_batches.clear();
349 Ok(())
350 }
351
352 async fn prepare_pending_files(&mut self) -> Result<(), ConnectorError> {
354 self.materialize_bulk_batches().await?;
355 self.close_writer_async().await?;
356
357 let paths = self.active_tmp_files.clone();
358 self.run_blocking(move || {
359 for path in paths {
360 let file = std::fs::OpenOptions::new()
361 .write(true)
363 .open(&path)
364 .map_err(|e| {
365 ConnectorError::WriteError(format!(
366 "cannot open '{}' for sync: {e}",
367 path.display()
368 ))
369 })?;
370 file.sync_all().map_err(|e| {
371 ConnectorError::WriteError(format!(
372 "file sync failed on '{}': {e}",
373 path.display()
374 ))
375 })?;
376 }
377 Ok::<(), ConnectorError>(())
378 })
379 .await
380 .map_err(|error| blocking_task_error("temporary-file sync failed", error))??;
381 Ok(())
382 }
383
384 async fn publish_pending_files(&mut self) -> Result<(), ConnectorError> {
387 if self.active_tmp_files.is_empty() {
388 return Ok(());
389 }
390 if self.writer.is_some() || !self.buffered_batches.is_empty() {
391 return Err(ConnectorError::InvalidState {
392 expected: "prepared temporary files".into(),
393 actual: "unflushed file data".into(),
394 });
395 }
396
397 let paths = self.active_tmp_files.clone();
398 let outcome = self
399 .run_blocking(move || -> Result<usize, FilePublicationError> {
400 let finals = paths
401 .iter()
402 .map(|path| {
403 let final_path = final_path_for_tmp(path)?;
404 match final_path.try_exists() {
405 Ok(false) => Ok(final_path),
406 Ok(true) => Err(ConnectorError::WriteError(format!(
407 "refusing to overwrite existing output '{}'",
408 final_path.display()
409 ))),
410 Err(e) => Err(ConnectorError::WriteError(format!(
411 "cannot inspect final output '{}': {e}",
412 final_path.display()
413 ))),
414 }
415 })
416 .collect::<Result<Vec<_>, ConnectorError>>()
417 .map_err(FilePublicationError::BeforeDispatch)?;
418
419 let mut published = 0;
420 for (tmp_path, final_path) in paths.iter().zip(&finals) {
421 if let Err(e) =
422 durable_rename(tmp_path, final_path, DurableRenameMode::NoReplace)
423 {
424 return Err(FilePublicationError::AfterDispatch {
425 published,
426 error: ConnectorError::WriteError(format!(
427 "cannot publish '{}' as '{}': {e}",
428 tmp_path.display(),
429 final_path.display()
430 )),
431 });
432 }
433 published += 1;
434 debug!(path = %final_path.display(), "file sink published immutable file");
435 }
436 Ok(published)
437 })
438 .await
439 .map_err(|error| ambiguous_blocking_task_error("file publication failed", error))?;
440
441 let published = match outcome {
442 Ok(published) => published,
443 Err(FilePublicationError::BeforeDispatch(error)) => return Err(error),
444 Err(FilePublicationError::AfterDispatch { published, error }) => {
445 let retryable = error.is_transient();
446 return Err(ConnectorError::outcome_unknown(
447 format!(
448 "file publication failed after dispatch; {published} file(s) were confirmed published: {error}"
449 ),
450 retryable,
451 ));
452 }
453 };
454 if published != self.active_tmp_files.len() {
455 return Err(ConnectorError::outcome_unknown(
456 format!(
457 "file publication reported {published} completed file(s) for {} pending path(s)",
458 self.active_tmp_files.len()
459 ),
460 false,
461 ));
462 }
463 self.active_tmp_files.clear();
464 self.finish_published_generation()?;
465 Ok(())
466 }
467
468 async fn flush_and_publish(&mut self) -> Result<(), ConnectorError> {
469 self.ensure_open()?;
470 self.prepare_pending_files().await?;
471 self.publish_pending_files().await
472 }
473}
474
475impl Default for FileSink {
476 fn default() -> Self {
477 Self::new()
478 }
479}
480
481impl Drop for FileSink {
482 fn drop(&mut self) {
483 self.retired.store(true, Ordering::Release);
484 }
485}
486
487impl std::fmt::Debug for FileSink {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 f.debug_struct("FileSink")
490 .field("is_open", &self.is_open)
491 .field("next_generation", &self.next_generation)
492 .field("buffered_batches", &self.buffered_batches.len())
493 .field("active_tmp_files", &self.active_tmp_files.len())
494 .finish()
495 }
496}
497
498#[async_trait]
499impl SinkConnector for FileSink {
500 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
501 Some(self.task_tracker.clone())
502 }
503
504 fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
505 FileSinkConfig::from_connector_config(config)?;
506 if !cfg!(any(unix, windows)) {
507 return Err(ConnectorError::ConfigurationError(
508 "durable file publication is unsupported on this platform".into(),
509 ));
510 }
511 Ok(SinkContract::new(
515 SinkConsistency::DurableAtLeastOnce,
516 SinkTopology::Singleton,
517 SinkInputMode::AppendOnly,
518 ))
519 }
520
521 async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
522 if self.is_open {
523 return Err(ConnectorError::InvalidState {
524 expected: "closed".into(),
525 actual: "already open".into(),
526 });
527 }
528 let sink_config = FileSinkConfig::from_connector_config(config)?;
529
530 let schema = config
532 .arrow_schema()
533 .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty()));
534 let encoder = build_encoder(sink_config.format, &schema, &sink_config)?;
535
536 let out_dir = PathBuf::from(&sink_config.path);
537 let prefix = sink_config.prefix.clone();
538 let extension = sink_config.format.extension().to_string();
539 let next_generation = self
540 .run_blocking(move || initialise_output_directory(&out_dir, &prefix, &extension))
541 .await
542 .map_err(|error| {
543 blocking_task_error("output-directory initialisation failed", error)
544 })??;
545
546 self.config = Some(sink_config);
547 self.schema = schema;
548 self.encoder = Some(encoder);
549 self.next_generation = next_generation;
550 self.current_segment = 0;
551 self.segment_bytes = 0;
552 self.buffered_batches.clear();
553 self.active_tmp_files.clear();
554 self.writer = None;
555 self.is_open = true;
556
557 info!(next_generation, "file sink opened");
558 Ok(())
559 }
560
561 async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
562 self.ensure_open()?;
563 let is_bulk = self
564 .config
565 .as_ref()
566 .ok_or_else(|| ConnectorError::InvalidState {
567 expected: "open".into(),
568 actual: "closed".into(),
569 })?
570 .format
571 .is_bulk_format();
572 let max_file_size = self.config.as_ref().and_then(|c| c.max_file_size);
573
574 if batch.num_rows() == 0 {
575 return Ok(WriteResult::new(0, 0));
576 }
577
578 let rows = batch.num_rows();
579
580 if is_bulk {
581 let max_buffered = self
582 .config
583 .as_ref()
584 .map_or(10_000, |config| config.max_buffered_batches);
585 if self.buffered_batches.len() >= max_buffered {
586 return Err(ConnectorError::WriteError(format!(
587 "file sink: bulk batch buffer full ({max_buffered} batches) — \
588 increase max_buffered_batches or flush more frequently"
589 )));
590 }
591 self.buffered_batches.push(batch.clone());
594 return Ok(WriteResult::new(rows, 0));
595 }
596
597 let encoder = self
600 .encoder
601 .take()
602 .ok_or_else(|| ConnectorError::InvalidState {
603 expected: "encoder ready".into(),
604 actual: "no encoder".into(),
605 })?;
606 let batch = batch.clone();
607 let (encoder, encoded) = self
608 .run_blocking(move || {
609 let encoded = encoder
610 .encode_batch(&batch)
611 .map_err(|e| ConnectorError::WriteError(format!("encode error: {e}")));
612 (encoder, encoded)
613 })
614 .await
615 .map_err(|error| blocking_task_error("row encoder worker failed", error))?;
616 self.encoder = Some(encoder);
617 let encoded = encoded?;
618 validate_encoded_row_count(rows, encoded.len())?;
619
620 self.ensure_writer_async().await?;
621 let writer = self
622 .writer
623 .as_ref()
624 .expect("ensure_writer_async just ran")
625 .clone();
626 let bytes_written = self
627 .run_blocking(move || -> Result<_, ConnectorError> {
628 let mut writer = writer.lock().map_err(|_| {
629 ConnectorError::WriteError("file writer lock was poisoned".into())
630 })?;
631 let mut total: u64 = 0;
632 for record_bytes in &encoded {
633 writer
634 .write_all(record_bytes)
635 .map_err(|e| ConnectorError::WriteError(format!("write error: {e}")))?;
636 writer
637 .write_all(b"\n")
638 .map_err(|e| ConnectorError::WriteError(format!("write error: {e}")))?;
639 total += record_bytes.len() as u64 + 1;
640 }
641 Ok(total)
642 })
643 .await
644 .map_err(|error| ambiguous_blocking_task_error("row file write failed", error))?
645 .map_err(|error| {
646 let retryable = error.is_transient();
647 ConnectorError::outcome_unknown(
648 format!("row temporary-file write may have partially completed: {error}"),
649 retryable,
650 )
651 })?;
652 self.segment_bytes = self
653 .segment_bytes
654 .checked_add(bytes_written)
655 .ok_or_else(|| {
656 ConnectorError::outcome_unknown(
657 "file data was staged but the byte counter is exhausted",
658 false,
659 )
660 })?;
661
662 if let Some(max_size) = max_file_size {
664 if self.segment_bytes >= max_size as u64 {
665 debug!("file sink: rotating at {} bytes", self.segment_bytes);
666 self.close_writer_async().await?;
667 self.advance_segment()?;
668 }
669 }
670
671 Ok(WriteResult::new(rows, bytes_written))
672 }
673
674 fn schema(&self) -> SchemaRef {
675 self.schema.clone()
676 }
677
678 fn suggested_write_timeout(&self) -> Duration {
679 Duration::from_secs(30)
680 }
681
682 async fn flush(&mut self) -> Result<(), ConnectorError> {
683 self.flush_and_publish().await
684 }
685
686 async fn close(&mut self) -> Result<(), ConnectorError> {
687 if !self.is_open {
688 return Ok(());
689 }
690 self.flush_and_publish().await?;
691 self.is_open = false;
692 self.config = None;
693 self.encoder = None;
694 info!("file sink closed");
695 Ok(())
696 }
697}
698
699fn validate_encoded_row_count(expected: usize, actual: usize) -> Result<(), ConnectorError> {
702 if actual == expected {
703 return Ok(());
704 }
705 Err(crate::error::SerdeError::RecordCountMismatch {
706 expected,
707 got: actual,
708 }
709 .into())
710}
711
712fn build_encoder(
713 format: FileFormat,
714 schema: &SchemaRef,
715 config: &FileSinkConfig,
716) -> Result<Box<dyn FormatEncoder>, ConnectorError> {
717 match format {
718 FileFormat::Csv => {
719 let csv_config = crate::schema::CsvEncoderConfig {
720 delimiter: b',',
721 has_header: false,
722 };
723 let encoder = crate::schema::CsvEncoder::with_config(schema.clone(), csv_config);
724 Ok(Box::new(encoder))
725 }
726 FileFormat::Json | FileFormat::Text => {
727 let encoder = crate::schema::JsonEncoder::new(schema.clone());
728 Ok(Box::new(encoder))
729 }
730 FileFormat::Parquet => {
731 use parquet::basic::Compression;
732 let compression = match config.compression.to_lowercase().as_str() {
733 "none" | "uncompressed" => Compression::UNCOMPRESSED,
734 "snappy" => Compression::SNAPPY,
735 "gzip" => Compression::GZIP(parquet::basic::GzipLevel::default()),
736 "zstd" => Compression::ZSTD(parquet::basic::ZstdLevel::default()),
737 "lz4" => Compression::LZ4,
738 other => {
739 return Err(ConnectorError::ConfigurationError(format!(
740 "unknown Parquet compression: '{other}'"
741 )));
742 }
743 };
744 let parquet_config = crate::schema::parquet::ParquetEncoderConfig::default()
745 .with_compression(compression);
746 let encoder =
747 crate::schema::parquet::ParquetEncoder::with_config(schema.clone(), parquet_config);
748 Ok(Box::new(encoder))
749 }
750 FileFormat::ArrowIpc => {
751 let encoder = super::arrow_ipc_codec::ArrowIpcEncoder::new(schema.clone());
752 Ok(Box::new(encoder))
753 }
754 }
755}
756
757fn final_path_for_tmp(tmp_path: &Path) -> Result<PathBuf, ConnectorError> {
758 let final_name = tmp_path
759 .file_name()
760 .and_then(|name| name.to_str())
761 .and_then(|name| name.strip_suffix(".tmp"))
762 .ok_or_else(|| {
763 ConnectorError::WriteError(format!(
764 "temporary output '{}' has no .tmp suffix",
765 tmp_path.display()
766 ))
767 })?;
768 Ok(tmp_path
769 .parent()
770 .unwrap_or_else(|| Path::new("."))
771 .join(final_name))
772}
773
774fn initialise_output_directory(
775 dir: &Path,
776 prefix: &str,
777 extension: &str,
778) -> Result<u64, ConnectorError> {
779 std::fs::create_dir_all(dir).map_err(|e| {
780 ConnectorError::WriteError(format!(
781 "cannot create output directory '{}': {e}",
782 dir.display()
783 ))
784 })?;
785 if !dir.is_dir() {
786 return Err(ConnectorError::WriteError(format!(
787 "file sink output '{}' is not a directory",
788 dir.display()
789 )));
790 }
791 scan_next_generation(dir, prefix, extension)
792}
793
794fn scan_next_generation(dir: &Path, prefix: &str, extension: &str) -> Result<u64, ConnectorError> {
802 let entries = std::fs::read_dir(dir).map_err(|e| {
803 ConnectorError::WriteError(format!(
804 "cannot scan output directory '{}': {e}",
805 dir.display()
806 ))
807 })?;
808 let name_prefix = format!("{prefix}_");
809 let final_suffix = format!(".{extension}");
810 let temporary_suffix = format!(".{extension}.tmp");
811 let mut highest = None::<u64>;
812 for entry in entries {
813 let entry = entry.map_err(|e| {
814 ConnectorError::WriteError(format!(
815 "cannot read an entry in output directory '{}': {e}",
816 dir.display()
817 ))
818 })?;
819 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
820 continue;
821 };
822 let Some(name) = name.strip_prefix(&name_prefix) else {
823 continue;
824 };
825 let body = name
826 .strip_suffix(&temporary_suffix)
827 .or_else(|| name.strip_suffix(&final_suffix));
828 let Some(body) = body else { continue };
829 let Some((generation, segment)) = body.rsplit_once('_') else {
830 continue;
831 };
832 if segment.parse::<usize>().is_err() {
833 continue;
834 }
835 let Ok(generation) = generation.parse::<u64>() else {
836 continue;
837 };
838 highest = Some(highest.map_or(generation, |current| current.max(generation)));
839 }
840 highest
841 .unwrap_or(0)
842 .checked_add(1)
843 .ok_or_else(|| ConnectorError::WriteError("file sink generation space is exhausted".into()))
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use arrow_array::{Int64Array, StringArray};
850 use arrow_schema::{DataType, Field, Schema};
851
852 fn test_schema() -> SchemaRef {
853 Arc::new(Schema::new(vec![
854 Field::new("id", DataType::Int64, false),
855 Field::new("name", DataType::Utf8, true),
856 ]))
857 }
858
859 fn test_batch(schema: &SchemaRef) -> RecordBatch {
860 RecordBatch::try_new(
861 schema.clone(),
862 vec![
863 Arc::new(Int64Array::from(vec![1, 2, 3])),
864 Arc::new(StringArray::from(vec!["a", "b", "c"])),
865 ],
866 )
867 .unwrap()
868 }
869
870 #[test]
871 fn row_encoder_cardinality_must_match_the_input() {
872 validate_encoded_row_count(3, 3).unwrap();
873 let short = validate_encoded_row_count(3, 2).unwrap_err();
874 let long = validate_encoded_row_count(3, 4).unwrap_err();
875 assert!(matches!(
876 short,
877 ConnectorError::Serde(crate::error::SerdeError::RecordCountMismatch {
878 expected: 3,
879 got: 2
880 })
881 ));
882 assert!(matches!(
883 long,
884 ConnectorError::Serde(crate::error::SerdeError::RecordCountMismatch {
885 expected: 3,
886 got: 4
887 })
888 ));
889 }
890
891 fn test_config(out_path: &Path, format: &str) -> ConnectorConfig {
892 let mut config = ConnectorConfig::new("files");
893 config.set("path", out_path.to_str().unwrap());
894 config.set("format", format);
895 config.set(
896 "_arrow_schema",
897 crate::config::encode_arrow_schema_ipc(test_schema().as_ref()),
898 );
899 config
900 }
901
902 fn final_files(out_path: &Path) -> Vec<PathBuf> {
903 let mut files = std::fs::read_dir(out_path)
904 .unwrap()
905 .flatten()
906 .map(|entry| entry.path())
907 .filter(|path| !path.to_string_lossy().ends_with(".tmp"))
908 .collect::<Vec<_>>();
909 files.sort();
910 files
911 }
912
913 #[test]
914 fn test_sink_default() {
915 let sink = FileSink::new();
916 assert!(!sink.is_open);
917 }
918
919 #[tokio::test]
920 async fn retired_generation_owns_uncooperative_blocking_child_until_exit() {
921 let sink = Arc::new(FileSink::new());
922 let tracker = sink
923 .terminal_task_tracker()
924 .expect("file sink owns blocking tasks");
925 let child_sink = Arc::clone(&sink);
926 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
927 let (release_tx, release_rx) = std::sync::mpsc::channel();
928
929 let caller = tokio::spawn(async move {
930 child_sink
931 .run_blocking(move || {
932 let _ = started_tx.send(());
933 let _ = release_rx.recv();
934 })
935 .await
936 });
937 started_rx.await.unwrap();
938 assert!(!tracker.is_terminated());
939
940 caller.abort();
941 let _ = caller.await;
942 drop(sink);
943 tokio::task::yield_now().await;
944 assert!(
945 !tracker.is_terminated(),
946 "retirement must retain a started blocking child"
947 );
948
949 release_tx.send(()).unwrap();
950 tokio::time::timeout(Duration::from_secs(1), tracker.wait_terminated())
951 .await
952 .expect("blocking generation did not reach terminal completion");
953 }
954
955 #[tokio::test]
956 async fn test_sink_open_creates_dir() {
957 let dir = tempfile::tempdir().unwrap();
958 let out_path = dir.path().join("output");
959
960 let mut sink = FileSink::new();
961 let config = test_config(&out_path, "json");
962
963 sink.open(&config).await.unwrap();
964 assert!(sink.is_open);
965 assert!(out_path.exists());
966 sink.close().await.unwrap();
967 }
968
969 #[tokio::test]
970 async fn open_preserves_orphan_tmp_and_advances_past_its_generation() {
971 let dir = tempfile::tempdir().unwrap();
972 let out_path = dir.path().join("output");
973 std::fs::create_dir_all(&out_path).unwrap();
974
975 let orphan = out_path.join("part_000007_000.jsonl.tmp");
978 std::fs::write(&orphan, b"orphan").unwrap();
979
980 let mut sink = FileSink::new();
981 let config = test_config(&out_path, "json");
982
983 sink.open(&config).await.unwrap();
984
985 assert!(orphan.exists());
986 assert_eq!(sink.next_generation, 8);
987
988 sink.close().await.unwrap();
989 }
990
991 #[test]
992 fn contract_is_singleton_durable_at_least_once() {
993 let dir = tempfile::tempdir().unwrap();
994 let config = test_config(dir.path(), "json");
995 let sink = FileSink::new();
996 let contract = sink.contract(&config).unwrap();
997 assert_eq!(contract.consistency, SinkConsistency::DurableAtLeastOnce);
998 assert_eq!(contract.topology, SinkTopology::Singleton);
999 assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
1000 assert_eq!(sink.suggested_write_timeout(), Duration::from_secs(30));
1001 }
1002
1003 #[tokio::test]
1004 async fn periodic_flush_publishes_pending_rows() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let out_path = dir.path().join("output");
1007 let config = test_config(&out_path, "json");
1008 let schema = test_schema();
1009
1010 let mut sink = FileSink::new();
1011 sink.open(&config).await.unwrap();
1012 sink.write_batch(&test_batch(&schema)).await.unwrap();
1013 assert_eq!(final_files(&out_path).len(), 0);
1014
1015 sink.flush().await.unwrap();
1016
1017 let files = final_files(&out_path);
1018 assert_eq!(files.len(), 1);
1019 assert!(files[0]
1020 .file_name()
1021 .unwrap()
1022 .to_string_lossy()
1023 .contains("_000001_"));
1024 assert!(sink.active_tmp_files.is_empty());
1025 assert!(sink.writer.is_none());
1026 sink.close().await.unwrap();
1027 }
1028
1029 #[tokio::test]
1030 async fn close_publishes_pending_rows() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let out_path = dir.path().join("output");
1033 let config = test_config(&out_path, "json");
1034 let schema = test_schema();
1035
1036 let mut sink = FileSink::new();
1037 sink.open(&config).await.unwrap();
1038 sink.write_batch(&test_batch(&schema)).await.unwrap();
1039 sink.close().await.unwrap();
1040
1041 assert_eq!(final_files(&out_path).len(), 1);
1042 assert!(!std::fs::read_dir(&out_path)
1043 .unwrap()
1044 .flatten()
1045 .any(|entry| entry.file_name().to_string_lossy().ends_with(".tmp")));
1046 }
1047
1048 #[tokio::test]
1049 async fn restart_uses_a_strictly_higher_generation() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let out_path = dir.path().join("output");
1052 let config = test_config(&out_path, "json");
1053 let schema = test_schema();
1054
1055 let mut first = FileSink::new();
1056 first.open(&config).await.unwrap();
1057 first.write_batch(&test_batch(&schema)).await.unwrap();
1058 first.flush().await.unwrap();
1059 first.close().await.unwrap();
1060 let first_path = final_files(&out_path).pop().unwrap();
1061 let first_contents = std::fs::read(&first_path).unwrap();
1062
1063 let mut restarted = FileSink::new();
1064 restarted.open(&config).await.unwrap();
1065 assert_eq!(restarted.next_generation, 2);
1066 restarted.write_batch(&test_batch(&schema)).await.unwrap();
1067 restarted.flush().await.unwrap();
1068 restarted.close().await.unwrap();
1069
1070 let files = final_files(&out_path);
1071 assert_eq!(files.len(), 2);
1072 assert!(files[0]
1073 .file_name()
1074 .unwrap()
1075 .to_string_lossy()
1076 .contains("_000001_"));
1077 assert!(files[1]
1078 .file_name()
1079 .unwrap()
1080 .to_string_lossy()
1081 .contains("_000002_"));
1082 assert_eq!(std::fs::read(first_path).unwrap(), first_contents);
1083 }
1084
1085 #[tokio::test]
1086 async fn periodic_flush_materializes_bulk_batches() {
1087 let dir = tempfile::tempdir().unwrap();
1088 let out_path = dir.path().join("output");
1089 let config = test_config(&out_path, "arrow");
1090 let schema = test_schema();
1091
1092 let mut sink = FileSink::new();
1093 sink.open(&config).await.unwrap();
1094 sink.write_batch(&test_batch(&schema)).await.unwrap();
1095 assert_eq!(sink.buffered_batches.len(), 1);
1096
1097 sink.flush().await.unwrap();
1098
1099 let files = final_files(&out_path);
1100 assert_eq!(files.len(), 1);
1101 assert_eq!(files[0].extension().unwrap(), "arrow");
1102 assert!(sink.buffered_batches.is_empty());
1103 sink.close().await.unwrap();
1104 }
1105
1106 #[tokio::test]
1107 async fn partial_publication_is_outcome_unknown_and_recovery_uses_new_generation() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let out_path = dir.path().join("output");
1110 let mut config = test_config(&out_path, "json");
1111 config.set("max_file_size", "1");
1112 let schema = test_schema();
1113
1114 let mut sink = FileSink::new();
1115 sink.open(&config).await.unwrap();
1116 sink.write_batch(&test_batch(&schema)).await.unwrap();
1117 sink.write_batch(&test_batch(&schema)).await.unwrap();
1118 sink.prepare_pending_files().await.unwrap();
1119 assert_eq!(sink.active_tmp_files.len(), 2);
1120
1121 std::fs::remove_file(&sink.active_tmp_files[1]).unwrap();
1124 let error = sink.publish_pending_files().await.unwrap_err();
1125 assert!(error.to_string().contains("cannot publish"));
1126 assert!(error.is_outcome_unknown());
1127 assert_eq!(final_files(&out_path).len(), 1);
1128 drop(sink);
1129
1130 let mut recovered = FileSink::new();
1133 recovered.open(&config).await.unwrap();
1134 assert_eq!(recovered.next_generation, 2);
1135 recovered.write_batch(&test_batch(&schema)).await.unwrap();
1136 recovered.flush().await.unwrap();
1137 assert_eq!(final_files(&out_path).len(), 2);
1138 recovered.close().await.unwrap();
1139 }
1140}