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