Skip to main content

laminar_connectors/files/
sink.rs

1//! File sink connector implementing [`SinkConnector`].
2//!
3//! Every flush publishes immutable files by atomically renaming same-directory
4//! temporary files. Publication generations are independent of checkpoint
5//! epochs so periodic flushes and process restarts cannot reuse final names.
6//! This is durable at-least-once output, not an atomic external checkpoint
7//! commit protocol.
8
9use 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
72/// File sink connector that publishes immutable rolling files.
73pub struct FileSink {
74    /// Parsed configuration.
75    config: Option<FileSinkConfig>,
76    /// Output schema.
77    schema: SchemaRef,
78    /// Format encoder.
79    encoder: Option<Box<dyn FormatEncoder>>,
80    /// Generation reserved for the next publication.
81    next_generation: u64,
82    /// Buffered batches awaiting a bulk-format file flush.
83    buffered_batches: Vec<RecordBatch>,
84    /// Current segment index within the publication generation.
85    current_segment: usize,
86    /// Bytes written in current segment.
87    segment_bytes: u64,
88    /// Buffered file writer for the current segment (row formats only).
89    writer: Option<Arc<Mutex<BufWriter<std::fs::File>>>>,
90    /// Active `.tmp` file paths in the current publication generation.
91    active_tmp_files: Vec<PathBuf>,
92    /// Whether the sink is open.
93    is_open: bool,
94    /// Prevents admitted but not-yet-running blocking work from starting after retirement.
95    retired: Arc<AtomicBool>,
96    /// Admission authority for blocking work owned by this generation.
97    task_owner: ConnectorTaskOwner,
98    /// Terminal observer handed to the runtime before this generation is dropped.
99    task_tracker: ConnectorTaskTracker,
100}
101
102impl FileSink {
103    /// Creates a new file sink with a placeholder schema.
104    #[must_use]
105    pub fn new() -> Self {
106        Self::with_registry(None)
107    }
108
109    /// Creates a new file sink with an optional Prometheus registry.
110    #[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    /// Opens a new same-directory temporary segment without ever truncating an
167    /// existing path. The filesystem call stays off the async runtime.
168    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        // Register the path before awaiting. If the caller cancels while the
187        // blocking open completes, a later retry still owns the file.
188        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    /// Ensures a writer is open for the current segment.
218    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    /// Closes and data-syncs the current writer on the blocking thread pool.
226    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    /// Encodes buffered bulk-format batches and writes their self-contained
277    /// file blob to the current temporary generation.
278    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    /// Close and sync every pending temporary file before publication.
353    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                    // Windows requires write access for FlushFileBuffers.
362                    .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    /// Atomically publishes every prepared file. Each rename is atomic, while
385    /// the set as a whole deliberately has at-least-once (not atomic) semantics.
386    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        // A checkpoint may fsync files, but this implementation has no
512        // recoverable external commit cursor/namespace. Do not overclaim it as
513        // checkpoint-committable.
514        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        // Validate the encoder before creating or cleaning any files.
531        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            // Bulk encoders must create a complete self-contained file, so
592            // retain batches until the next periodic/checkpoint/close flush.
593            self.buffered_batches.push(batch.clone());
594            return Ok(WriteResult::new(rows, 0));
595        }
596
597        // Row encoding and file writes can both be CPU- or filesystem-bound.
598        // Keep them off the Tokio worker pool and owned by this generation.
599        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        // Size-based rotation within the current publication generation.
663        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
699// ── Helpers ──────────────────────────────────────────────────────────
700
701fn 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
794/// Finds a generation strictly above every existing final or temporary file
795/// for this exact prefix and format. Temporary files are deliberately retained:
796/// deleting them during `open` could destroy a still-live writer that was
797/// accidentally configured with the same target. They can be garbage-collected
798/// only by an operator after establishing exclusive ownership of the target.
799/// Malformed unrelated names are ignored; `create_new` and final-path
800/// no-overwrite checks remain authoritative at publication time.
801fn 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        // A prior crash may leave an unpublished file. Startup must not delete
976        // it because another process could still own that path.
977        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        // Model a filesystem failure after preparation but before the second
1122        // rename. The first segment publishes; the missing second one fails.
1123        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        // Outcome-unknown retires the old connector. Recovery scans the partial
1131        // final output and replays into a strictly newer immutable generation.
1132        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}