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::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
74pub struct FileSink {
76 config: Option<FileSinkConfig>,
78 schema: SchemaRef,
80 writer_mapping: Option<(SchemaRef, Vec<usize>)>,
81 namespace_owner: Option<Arc<std::fs::File>>,
82 encoder: Option<Box<dyn FormatEncoder>>,
84 next_generation: u64,
86 buffered_batches: Vec<RecordBatch>,
88 current_segment: usize,
90 segment_bytes: u64,
92 writer: Option<Arc<Mutex<BufWriter<std::fs::File>>>>,
94 active_tmp_files: Vec<PathBuf>,
96 is_open: bool,
98 retired: Arc<AtomicBool>,
100 task_owner: ConnectorTaskOwner,
102 task_tracker: ConnectorTaskTracker,
104}
105
106impl FileSink {
107 #[must_use]
109 pub fn new() -> Self {
110 Self::with_registry(None)
111 }
112
113 #[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 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 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 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 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 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 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 .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 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 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 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 self.buffered_batches.push(batch.clone());
666 return Ok(WriteResult::new(rows, 0));
667 }
668
669 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 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
773fn 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 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 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 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}