1use std::collections::VecDeque;
7use std::sync::Arc;
8
9use arrow_array::RecordBatch;
10use arrow_schema::{DataType, Field, Schema, SchemaRef};
11use async_trait::async_trait;
12use tracing::{debug, info};
13
14use crate::checkpoint::SourceCheckpoint;
15use crate::config::ConnectorConfig;
16use crate::connector::{
17 ConnectorTaskGuard, ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceConnector,
18 SourceConsistency, SourceContract, SourceInputMode, SourcePosition, SourceStart,
19 SourceTopology,
20};
21use crate::error::ConnectorError;
22use crate::schema::traits::FormatDecoder;
23use crate::schema::types::RawRecord;
24
25use super::config::{local_files_path, FileFormat, FileSourceConfig};
26use super::discovery::{DiscoveredFile, DiscoveryConfig, FileDiscoveryEngine};
27use super::manifest::FileIngestionManifest;
28use super::text_decoder::TextLineDecoder;
29
30const DISCOVERY_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
31const FILE_CHECKPOINT_CONNECTOR: &str = "files";
32const CHECKPOINT_VERSION_METADATA: &str = "checkpoint.version";
33const FILE_CHECKPOINT_VERSION: &str = "1";
34
35fn validate_file_checkpoint(checkpoint: &SourceCheckpoint) -> Result<(), ConnectorError> {
36 match checkpoint.get_metadata("connector") {
37 Some(FILE_CHECKPOINT_CONNECTOR) => {}
38 Some(connector) => {
39 return Err(ConnectorError::ConfigurationError(format!(
40 "file checkpoint belongs to connector '{connector}'"
41 )));
42 }
43 None => {
44 return Err(ConnectorError::ConfigurationError(
45 "file checkpoint is missing connector identity".into(),
46 ));
47 }
48 }
49 if checkpoint.get_metadata(CHECKPOINT_VERSION_METADATA) != Some(FILE_CHECKPOINT_VERSION) {
50 return Err(ConnectorError::ConfigurationError(format!(
51 "file checkpoint requires {CHECKPOINT_VERSION_METADATA}={FILE_CHECKPOINT_VERSION}"
52 )));
53 }
54 Ok(())
55}
56
57#[async_trait]
58trait FileReader: Send + Sync {
59 async fn read(
60 &self,
61 path: &str,
62 task_guard: ConnectorTaskGuard,
63 ) -> Result<Vec<u8>, ConnectorError>;
64}
65
66struct LocalFileReader;
67
68#[async_trait]
69impl FileReader for LocalFileReader {
70 async fn read(
71 &self,
72 path: &str,
73 task_guard: ConnectorTaskGuard,
74 ) -> Result<Vec<u8>, ConnectorError> {
75 read_file_bytes(path, task_guard).await
76 }
77}
78
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(deny_unknown_fields)]
81struct FileProgress {
82 path: String,
83 size: u64,
84 modified_ms: u64,
85 content_sha256: String,
86 next_row: u64,
87}
88
89#[derive(Debug, Clone)]
90struct PendingFile {
91 discovered: DiscoveredFile,
92 resume: Option<FileProgress>,
93}
94
95#[derive(Debug)]
96struct DecodedFile {
97 discovered: DiscoveredFile,
98 content_sha256: String,
99 records: RecordBatch,
100 next_row: usize,
101}
102
103pub struct FileSource {
108 config: Option<FileSourceConfig>,
110 schema: SchemaRef,
112 decoder: Option<Box<dyn FormatDecoder>>,
114 discovery: Option<FileDiscoveryEngine>,
116 manifest: FileIngestionManifest,
118 pending_files: VecDeque<PendingFile>,
121 current_file: Option<DecodedFile>,
123 reader: Arc<dyn FileReader>,
125 is_open: bool,
127 restart_forbidden: bool,
129 task_owner: ConnectorTaskOwner,
131 task_tracker: ConnectorTaskTracker,
133}
134
135impl FileSource {
136 #[must_use]
138 pub fn new() -> Self {
139 Self::with_registry(None)
140 }
141
142 #[must_use]
144 pub fn with_registry(_registry: Option<&prometheus::Registry>) -> Self {
145 let empty_schema = Arc::new(Schema::empty());
146 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
147 Self {
148 config: None,
149 schema: empty_schema,
150 decoder: None,
151 discovery: None,
152 manifest: FileIngestionManifest::new(),
153 pending_files: VecDeque::new(),
154 current_file: None,
155 reader: Arc::new(LocalFileReader),
156 is_open: false,
157 restart_forbidden: false,
158 task_owner,
159 task_tracker,
160 }
161 }
162
163 async fn close_until(&mut self, deadline: tokio::time::Instant) -> Result<(), ConnectorError> {
164 let Some(discovery) = self.discovery.as_mut() else {
165 if self.is_open {
166 self.restart_forbidden = true;
167 return Err(ConnectorError::InvalidState {
168 expected: "running discovery task".into(),
169 actual: "open source without discovery task".into(),
170 });
171 }
172 return Ok(());
173 };
174
175 let was_restart_forbidden = self.restart_forbidden;
178 self.restart_forbidden = true;
179 discovery.abort_and_join_until(deadline).await?;
180
181 self.discovery = None;
182 self.decoder = None;
183 self.pending_files.clear();
184 self.current_file = None;
185 self.is_open = false;
186 self.restart_forbidden = was_restart_forbidden;
187 info!("file source closed");
188 Ok(())
189 }
190}
191
192impl Default for FileSource {
193 fn default() -> Self {
194 Self::new()
195 }
196}
197
198impl std::fmt::Debug for FileSource {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct("FileSource")
201 .field("is_open", &self.is_open)
202 .field("restart_forbidden", &self.restart_forbidden)
203 .field("schema_fields", &self.schema.fields().len())
204 .field("manifest_count", &self.manifest.processed_count())
205 .field("pending_files", &self.pending_files.len())
206 .field("has_current_file", &self.current_file.is_some())
207 .finish()
208 }
209}
210
211#[async_trait]
212impl SourceConnector for FileSource {
213 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
214 Some(self.task_tracker.clone())
215 }
216
217 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
218 if self.is_open || self.discovery.is_some() || self.restart_forbidden {
219 return Err(ConnectorError::InvalidState {
220 expected: "new or cleanly closed file source".into(),
221 actual: if self.restart_forbidden {
222 "retired file source generation".into()
223 } else {
224 "file source already open".into()
225 },
226 });
227 }
228
229 let (config, position, _) = request.into_parts();
230 let src_config = FileSourceConfig::from_connector_config(&config)?;
231
232 let (manifest, progress) = match position {
236 SourcePosition::Initial => (FileIngestionManifest::new(), None),
237 SourcePosition::Resume {
238 attempt,
239 checkpoint,
240 } => {
241 validate_file_checkpoint(&checkpoint)?;
242 if checkpoint.get_offset("manifest").is_none() {
243 return Err(ConnectorError::ConfigurationError(format!(
244 "file checkpoint {attempt:?} is missing required manifest state"
245 )));
246 }
247 let manifest =
248 FileIngestionManifest::from_checkpoint(&checkpoint).map_err(|e| {
249 ConnectorError::ConfigurationError(format!(
250 "invalid file manifest in checkpoint {attempt:?}: {e}"
251 ))
252 })?;
253 let progress = checkpoint
254 .get_offset("file_progress")
255 .map(serde_json::from_str::<FileProgress>)
256 .transpose()
257 .map_err(|e| {
258 ConnectorError::ConfigurationError(format!(
259 "invalid file progress in checkpoint {attempt:?}: {e}"
260 ))
261 })?
262 .map(|mut progress| {
263 progress.path = local_files_path(&progress.path)?;
264 Ok::<_, ConnectorError>(progress)
265 })
266 .transpose()?;
267 if progress
268 .as_ref()
269 .is_some_and(|p| manifest.contains(&p.path) || p.next_row == 0)
270 {
271 return Err(ConnectorError::ConfigurationError(format!(
272 "file checkpoint {attempt:?} contains contradictory progress"
273 )));
274 }
275 (manifest, progress)
276 }
277 };
278
279 let format = match src_config.format {
281 Some(f) => f,
282 None => FileFormat::from_extension(&src_config.path).ok_or_else(|| {
283 ConnectorError::ConfigurationError(
284 "cannot detect format from path; specify 'format' explicitly".into(),
285 )
286 })?,
287 };
288
289 let (decoder, schema) = build_decoder_and_schema(format, &src_config, &config)?;
291
292 let final_schema = if src_config.include_metadata {
294 let mut fields: Vec<Field> =
295 schema.fields().iter().map(|f| f.as_ref().clone()).collect();
296 fields.push(Field::new(
297 "_metadata",
298 DataType::Struct(
299 vec![
300 Field::new("file_path", DataType::Utf8, false),
301 Field::new("file_name", DataType::Utf8, false),
302 Field::new("file_size", DataType::UInt64, false),
303 Field::new(
304 "file_modification_time",
305 DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
306 true,
307 ),
308 ]
309 .into(),
310 ),
311 false,
312 ));
313 Arc::new(Schema::new(fields))
314 } else {
315 schema
316 };
317
318 let discovery_config = DiscoveryConfig {
320 path: src_config.path.clone(),
321 poll_interval: src_config.poll_interval,
322 stabilisation_delay: src_config.stabilisation_delay,
323 glob_pattern: src_config.glob_pattern.clone(),
324 };
325 let known = Arc::new(manifest.snapshot_for_dedup());
326 let discovery_guard = self
327 .task_owner
328 .track()
329 .expect("live file source cannot have a retired task owner");
330 let initial_scan_guard = self
331 .task_owner
332 .track()
333 .expect("live file source cannot have a retired task owner");
334 let discovery = FileDiscoveryEngine::start(
335 discovery_config,
336 known,
337 discovery_guard,
338 initial_scan_guard,
339 );
340
341 self.config = Some(src_config);
342 self.schema = final_schema;
343 self.decoder = Some(decoder);
344 self.discovery = Some(discovery);
345 self.manifest = manifest;
346 self.pending_files.clear();
347 self.current_file = None;
348 if let Some(progress) = progress {
349 self.pending_files.push_back(PendingFile {
350 discovered: DiscoveredFile {
351 path: progress.path.clone(),
352 size: progress.size,
353 modified_ms: progress.modified_ms,
354 },
355 resume: Some(progress),
356 });
357 }
358 self.is_open = true;
359
360 info!(
361 "file source opened: format={format:?}, schema_fields={}",
362 self.schema.fields().len()
363 );
364 Ok(())
365 }
366
367 async fn poll_batch(
368 &mut self,
369 max_records: usize,
370 ) -> Result<Option<SourceBatch>, ConnectorError> {
371 if max_records == 0 {
372 return Err(ConnectorError::ConfigurationError(
373 "file source poll max_records must be greater than zero".into(),
374 ));
375 }
376
377 let config = self
378 .config
379 .clone()
380 .ok_or_else(|| ConnectorError::InvalidState {
381 expected: "started".into(),
382 actual: "closed".into(),
383 })?;
384 if self.decoder.is_none() {
385 return Err(ConnectorError::InvalidState {
386 expected: "decoder ready".into(),
387 actual: "no decoder".into(),
388 });
389 }
390
391 loop {
392 if let Some(mut decoded) = self.current_file.take() {
393 let rows_left = decoded.records.num_rows() - decoded.next_row;
394 let row_count = rows_left.min(max_records);
395 let result = decoded.records.slice(decoded.next_row, row_count);
396 decoded.next_row += row_count;
397
398 if decoded.next_row == decoded.records.num_rows() {
399 self.manifest.insert(decoded.discovered.path);
400 } else {
401 self.current_file = Some(decoded);
402 }
403
404 return Ok(Some(SourceBatch::new(result)));
407 }
408
409 if self.pending_files.is_empty() {
410 let discovery =
411 self.discovery
412 .as_mut()
413 .ok_or_else(|| ConnectorError::InvalidState {
414 expected: "discovery running".into(),
415 actual: "no discovery".into(),
416 })?;
417 let discovered = match discovery.drain(config.max_files_per_poll).await {
418 Ok(discovered) => discovered,
419 Err(error) => {
420 self.restart_forbidden = true;
421 return Err(error);
422 }
423 };
424 self.pending_files
425 .extend(discovered.into_iter().map(|discovered| PendingFile {
426 discovered,
427 resume: None,
428 }));
429 }
430
431 let Some(pending) = self.pending_files.front().cloned() else {
432 return Ok(None);
433 };
434
435 if self.manifest.contains(&pending.discovered.path) {
436 self.pending_files.pop_front();
437 continue;
438 }
439
440 if pending.discovered.size > config.max_file_bytes as u64 {
441 return Err(ConnectorError::ConfigurationError(format!(
442 "file '{}' size {} exceeds max_file_bytes {}",
443 pending.discovered.path, pending.discovered.size, config.max_file_bytes
444 )));
445 }
446
447 let read_guard = self
448 .task_owner
449 .track()
450 .expect("live file source cannot have a retired task owner");
451 let bytes = Arc::clone(&self.reader)
452 .read(&pending.discovered.path, read_guard)
453 .await?;
454 if bytes.len() > config.max_file_bytes {
455 return Err(ConnectorError::ConfigurationError(format!(
456 "file '{}' actual size {} exceeds max_file_bytes {}",
457 pending.discovered.path,
458 bytes.len(),
459 config.max_file_bytes
460 )));
461 }
462 if u64::try_from(bytes.len()).ok() != Some(pending.discovered.size) {
463 return Err(ConnectorError::ReadError(format!(
464 "file '{}' changed size after discovery (expected {}, read {})",
465 pending.discovered.path,
466 pending.discovered.size,
467 bytes.len()
468 )));
469 }
470
471 let content_sha256 = sha256_hex(&bytes);
472 let next_row = if let Some(progress) = &pending.resume {
473 if progress.content_sha256 != content_sha256 {
474 return Err(ConnectorError::ConfigurationError(format!(
475 "file '{}' changed content since its checkpointed partial read",
476 pending.discovered.path
477 )));
478 }
479 usize::try_from(progress.next_row).map_err(|_| {
480 ConnectorError::ConfigurationError(format!(
481 "file '{}' checkpoint row {} exceeds this runtime's address space",
482 pending.discovered.path, progress.next_row
483 ))
484 })?
485 } else {
486 0
487 };
488
489 let mut records = self
490 .decoder
491 .as_ref()
492 .expect("decoder checked above")
493 .decode_batch(&[RawRecord::new(bytes)])
494 .map_err(ConnectorError::from)?;
495 if config.include_metadata {
496 records = append_metadata_column(
497 &records,
498 &pending.discovered.path,
499 pending.discovered.size,
500 pending.discovered.modified_ms,
501 )?;
502 }
503 if pending.resume.is_some() && next_row >= records.num_rows() {
504 return Err(ConnectorError::ConfigurationError(format!(
505 "file '{}' checkpoint row {} is outside decoded row count {}",
506 pending.discovered.path,
507 next_row,
508 records.num_rows()
509 )));
510 }
511
512 self.pending_files.pop_front();
513 if records.num_rows() == 0 {
514 debug!(
515 "file source: empty batch from '{}'",
516 pending.discovered.path
517 );
518 self.manifest.insert(pending.discovered.path);
519 return Ok(None);
520 }
521 self.current_file = Some(DecodedFile {
522 discovered: pending.discovered,
523 content_sha256,
524 records,
525 next_row,
526 });
527 }
529 }
530
531 fn schema(&self) -> SchemaRef {
532 self.schema.clone()
533 }
534
535 fn checkpoint(&self) -> SourceCheckpoint {
536 let mut cp = SourceCheckpoint::new();
537 cp.set_metadata("connector", FILE_CHECKPOINT_CONNECTOR);
538 cp.set_metadata(CHECKPOINT_VERSION_METADATA, FILE_CHECKPOINT_VERSION);
539 self.manifest.to_checkpoint(&mut cp);
540 if let Some(file) = &self.current_file {
541 let progress = FileProgress {
542 path: file.discovered.path.clone(),
543 size: file.discovered.size,
544 modified_ms: file.discovered.modified_ms,
545 content_sha256: file.content_sha256.clone(),
546 next_row: u64::try_from(file.next_row)
547 .expect("Arrow record counts are representable as u64"),
548 };
549 cp.set_offset(
550 "file_progress",
551 serde_json::to_string(&progress)
552 .expect("file progress contains only infallibly serializable fields"),
553 );
554 }
555 cp
556 }
557
558 async fn close(&mut self) -> Result<(), ConnectorError> {
559 self.close_until(tokio::time::Instant::now() + DISCOVERY_CLOSE_TIMEOUT)
560 .await
561 }
562
563 fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
564 Ok(SourceContract::new(
567 SourceConsistency::Replayable,
568 SourceTopology::Singleton,
569 SourceInputMode::AppendOnly,
570 ))
571 }
572}
573
574fn build_decoder_and_schema(
577 format: FileFormat,
578 src_config: &FileSourceConfig,
579 connector_config: &ConnectorConfig,
580) -> Result<(Box<dyn FormatDecoder>, SchemaRef), ConnectorError> {
581 match format {
582 FileFormat::Csv => {
583 let schema = connector_config.arrow_schema().unwrap_or_else(|| {
584 Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
585 });
586 let csv_config = crate::schema::CsvDecoderConfig {
587 delimiter: src_config.csv_delimiter,
588 has_header: src_config.csv_has_header,
589 ..crate::schema::CsvDecoderConfig::default()
590 };
591 let decoder = crate::schema::CsvDecoder::with_config(schema.clone(), csv_config);
592 Ok((Box::new(decoder), schema))
593 }
594 FileFormat::Json => {
595 let schema = connector_config.arrow_schema().unwrap_or_else(|| {
596 Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
597 });
598 let json_config = crate::schema::JsonDecoderConfig::default();
599 let decoder = crate::schema::JsonDecoder::with_config(schema.clone(), json_config);
600 Ok((Box::new(decoder), schema))
601 }
602 FileFormat::Text => {
603 let decoder = TextLineDecoder::new();
604 let schema = decoder.output_schema();
605 Ok((Box::new(decoder), schema))
606 }
607 FileFormat::Parquet => {
608 let schema = connector_config.arrow_schema().unwrap_or_else(|| {
611 Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
612 });
613 let decoder = crate::schema::parquet::ParquetDecoder::new(schema.clone());
614 Ok((Box::new(decoder), schema))
615 }
616 FileFormat::ArrowIpc => {
617 let schema = connector_config.arrow_schema().unwrap_or_else(|| {
621 Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
622 });
623 let decoder = super::arrow_ipc_codec::ArrowIpcDecoder::new(schema.clone());
624 Ok((Box::new(decoder), schema))
625 }
626 }
627}
628
629fn sha256_hex(bytes: &[u8]) -> String {
630 use sha2::{Digest, Sha256};
631
632 format!("{:x}", Sha256::digest(bytes))
633}
634
635async fn read_file_bytes(
636 path: &str,
637 task_guard: ConnectorTaskGuard,
638) -> Result<Vec<u8>, ConnectorError> {
639 let read_path = path.to_owned();
640 let error_path = read_path.clone();
641 tokio::task::spawn_blocking(move || {
642 let _task_guard = task_guard;
643 std::fs::read(read_path)
644 })
645 .await
646 .map_err(|e| ConnectorError::ReadError(format!("file read worker failed: {e}")))?
647 .map_err(|e| ConnectorError::ReadError(format!("cannot read file '{error_path}': {e}")))
648}
649
650fn append_metadata_column(
651 batch: &RecordBatch,
652 file_path: &str,
653 file_size: u64,
654 modified_ms: u64,
655) -> Result<RecordBatch, ConnectorError> {
656 use arrow_array::{ArrayRef, StringArray, StructArray, UInt64Array};
657
658 let n = batch.num_rows();
659 let file_name = std::path::Path::new(file_path)
660 .file_name()
661 .and_then(|n| n.to_str())
662 .unwrap_or(file_path);
663
664 let path_array: ArrayRef = Arc::new(StringArray::from(vec![file_path; n]));
665 let name_array: ArrayRef = Arc::new(StringArray::from(vec![file_name; n]));
666 let size_array: ArrayRef = Arc::new(UInt64Array::from(vec![file_size; n]));
667 #[allow(clippy::cast_possible_wrap)]
668 let mod_array: ArrayRef = Arc::new(arrow_array::TimestampMillisecondArray::from(vec![
669 modified_ms as i64;
670 n
671 ]));
672
673 let fields = vec![
674 Field::new("file_path", DataType::Utf8, false),
675 Field::new("file_name", DataType::Utf8, false),
676 Field::new("file_size", DataType::UInt64, false),
677 Field::new(
678 "file_modification_time",
679 DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
680 true,
681 ),
682 ];
683 let struct_array = StructArray::try_new(
684 fields.into(),
685 vec![path_array, name_array, size_array, mod_array],
686 None,
687 )
688 .map_err(|e| ConnectorError::ReadError(format!("metadata struct error: {e}")))?;
689
690 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
692 columns.push(Arc::new(struct_array));
693
694 let mut fields: Vec<Field> = batch
695 .schema()
696 .fields()
697 .iter()
698 .map(|f| f.as_ref().clone())
699 .collect();
700 fields.push(Field::new(
701 "_metadata",
702 DataType::Struct(
703 vec![
704 Field::new("file_path", DataType::Utf8, false),
705 Field::new("file_name", DataType::Utf8, false),
706 Field::new("file_size", DataType::UInt64, false),
707 Field::new(
708 "file_modification_time",
709 DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
710 true,
711 ),
712 ]
713 .into(),
714 ),
715 false,
716 ));
717
718 RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
719 .map_err(|e| ConnectorError::ReadError(format!("metadata append error: {e}")))
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::connector::{DeliveryGuarantee, SourcePosition, SourceStart};
726 use laminar_core::checkpoint::CheckpointAttempt;
727 use std::collections::BTreeMap;
728 use tokio::sync::Notify;
729
730 struct TestFileReader {
731 files: BTreeMap<String, Vec<u8>>,
732 blocked_path: Option<String>,
733 read_started: Notify,
734 release_read: Notify,
735 }
736
737 struct BlockingChildFileReader {
738 bytes: Vec<u8>,
739 started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
740 release: std::sync::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
741 }
742
743 #[async_trait]
744 impl FileReader for TestFileReader {
745 async fn read(
746 &self,
747 path: &str,
748 _task_guard: ConnectorTaskGuard,
749 ) -> Result<Vec<u8>, ConnectorError> {
750 if self.blocked_path.as_deref() == Some(path) {
751 self.read_started.notify_one();
752 self.release_read.notified().await;
753 }
754 self.files
755 .get(path)
756 .cloned()
757 .ok_or_else(|| ConnectorError::ReadError(format!("missing test file '{path}'")))
758 }
759 }
760
761 #[async_trait]
762 impl FileReader for BlockingChildFileReader {
763 async fn read(
764 &self,
765 _path: &str,
766 task_guard: ConnectorTaskGuard,
767 ) -> Result<Vec<u8>, ConnectorError> {
768 let started = self.started.lock().unwrap().take().unwrap();
769 let release = self.release.lock().unwrap().take().unwrap();
770 let bytes = self.bytes.clone();
771 tokio::task::spawn_blocking(move || {
772 let _task_guard = task_guard;
773 let _ = started.send(());
774 let _ = release.recv();
775 bytes
776 })
777 .await
778 .map_err(|e| ConnectorError::ReadError(format!("test file worker failed: {e}")))
779 }
780 }
781
782 fn start_request(config: ConnectorConfig, position: SourcePosition) -> SourceStart {
783 SourceStart::new(config, position, DeliveryGuarantee::AtLeastOnce).unwrap()
784 }
785
786 async fn started_text_source(
787 directory: &std::path::Path,
788 position: SourcePosition,
789 ) -> FileSource {
790 let config = text_source_config(directory);
791 let mut source = FileSource::new();
792 source.start(start_request(config, position)).await.unwrap();
793 source
794 }
795
796 #[cfg(unix)]
797 #[tokio::test]
798 async fn local_path_with_url_like_component_can_be_read() {
799 let directory = tempfile::tempdir().unwrap();
800 let nested = directory.path().join("url:").join("nested");
801 std::fs::create_dir_all(&nested).unwrap();
802 std::fs::write(nested.join("data.txt"), b"local").unwrap();
803 let path = format!("{}/url://nested/data.txt", directory.path().display());
804 let (owner, _tracker) = ConnectorTaskOwner::new();
805
806 let bytes = read_file_bytes(&path, owner.track().unwrap())
807 .await
808 .unwrap();
809
810 assert_eq!(bytes, b"local");
811 }
812
813 fn text_source_config(directory: &std::path::Path) -> ConnectorConfig {
814 let mut config = ConnectorConfig::new("files");
815 config.set("path", directory.to_string_lossy().to_string());
816 config.set("format", "text");
817 config.set("stabilisation_delay", "60s");
818 config
819 }
820
821 async fn install_blocked_discovery(source: &mut FileSource, blocked_for: std::time::Duration) {
822 source
823 .discovery
824 .as_mut()
825 .unwrap()
826 .abort_and_join_until(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
827 .await
828 .unwrap();
829 let guard = source.task_owner.track().unwrap();
830 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
831 let handle = tokio::spawn(async move {
832 let _guard = guard;
833 let _ = started_tx.send(());
834 std::thread::sleep(blocked_for);
835 Ok(())
836 });
837 source
838 .discovery
839 .as_mut()
840 .unwrap()
841 .install_task_for_test(handle);
842 started_rx.await.unwrap();
843 }
844
845 fn staged(path: &str, bytes: &[u8]) -> PendingFile {
846 PendingFile {
847 discovered: DiscoveredFile {
848 path: path.into(),
849 size: u64::try_from(bytes.len()).unwrap(),
850 modified_ms: 1234,
851 },
852 resume: None,
853 }
854 }
855
856 #[test]
857 fn test_file_source_default() {
858 let source = FileSource::new();
859 assert!(!source.is_open);
860 assert_eq!(source.manifest.processed_count(), 0);
861 }
862
863 #[tokio::test]
864 async fn test_open_missing_path() {
865 let mut source = FileSource::new();
866 let config = ConnectorConfig::new("files");
867 let result = source
868 .start(start_request(config, SourcePosition::Initial))
869 .await;
870 assert!(result.is_err());
871 }
872
873 #[tokio::test]
874 async fn test_open_with_text_format() {
875 let directory = tempfile::tempdir().unwrap();
876 let mut source = FileSource::new();
877 let mut config = ConnectorConfig::new("files");
878 config.set("path", directory.path().to_string_lossy().to_string());
879 config.set("format", "text");
880 let result = source
881 .start(start_request(config, SourcePosition::Initial))
882 .await;
883 assert!(result.is_ok());
884 assert!(source.is_open);
885 assert_eq!(source.schema().field(0).name(), "line");
886 source.close().await.unwrap();
887 }
888
889 #[tokio::test]
890 async fn start_while_open_is_rejected_before_request_validation() {
891 let directory = tempfile::tempdir().unwrap();
892 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
893 let original_path = source.config.as_ref().unwrap().path.clone();
894
895 let error = source
896 .start(start_request(
897 ConnectorConfig::new("files"),
898 SourcePosition::Initial,
899 ))
900 .await
901 .expect_err("an open source must reject a second start");
902 assert!(matches!(error, ConnectorError::InvalidState { .. }));
903 assert_eq!(source.config.as_ref().unwrap().path, original_path);
904 assert!(source.is_open);
905 source.close().await.unwrap();
906 }
907
908 #[tokio::test]
909 async fn discovery_failure_reaches_poll_and_retires_the_instance() {
910 let directory = tempfile::tempdir().unwrap();
911 let missing = directory.path().join("missing");
912 let mut source = FileSource::new();
913 source
914 .start(start_request(
915 text_source_config(&missing),
916 SourcePosition::Initial,
917 ))
918 .await
919 .unwrap();
920
921 let error = tokio::time::timeout(std::time::Duration::from_secs(1), async {
922 loop {
923 match source.poll_batch(10).await {
924 Ok(None) => tokio::task::yield_now().await,
925 Ok(Some(batch)) => panic!("unexpected batch with {} rows", batch.num_rows()),
926 Err(error) => break error,
927 }
928 }
929 })
930 .await
931 .expect("discovery failure remained silent");
932 assert!(matches!(error, ConnectorError::InvalidState { .. }));
933 assert!(!error.is_transient());
934 assert!(error.to_string().contains("is not a directory"));
935 assert!(source.restart_forbidden);
936
937 let retry_directory = tempfile::tempdir().unwrap();
938 let retry_error = source
939 .start(start_request(
940 text_source_config(retry_directory.path()),
941 SourcePosition::Initial,
942 ))
943 .await
944 .expect_err("failed source instance must not restart");
945 assert!(matches!(retry_error, ConnectorError::InvalidState { .. }));
946 }
947
948 #[tokio::test]
949 async fn clean_close_allows_same_instance_restart() {
950 let first = tempfile::tempdir().unwrap();
951 let second = tempfile::tempdir().unwrap();
952 let mut source = started_text_source(first.path(), SourcePosition::Initial).await;
953 source.close().await.unwrap();
954
955 source
956 .start(start_request(
957 text_source_config(second.path()),
958 SourcePosition::Initial,
959 ))
960 .await
961 .unwrap();
962 assert!(source.is_open);
963 assert_eq!(
964 source.config.as_ref().unwrap().path.as_str(),
965 second.path().to_string_lossy().as_ref()
966 );
967 source.close().await.unwrap();
968 }
969
970 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
971 async fn timed_out_close_retains_task_and_forbids_restart() {
972 let directory = tempfile::tempdir().unwrap();
973 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
974 install_blocked_discovery(&mut source, std::time::Duration::from_millis(250)).await;
975 let terminal = source.terminal_task_tracker().unwrap();
976
977 let error = source
978 .close_until(tokio::time::Instant::now() + std::time::Duration::from_millis(5))
979 .await
980 .expect_err("blocked discovery must exceed the close deadline");
981 assert!(matches!(error, ConnectorError::Timeout(_)));
982 assert!(source.discovery.is_some());
983 assert!(source.restart_forbidden);
984
985 let restart = source
986 .start(start_request(
987 text_source_config(directory.path()),
988 SourcePosition::Initial,
989 ))
990 .await;
991 assert!(matches!(restart, Err(ConnectorError::InvalidState { .. })));
992 drop(source);
993 assert!(
994 !terminal.is_terminated(),
995 "timed-out close detached an active discovery task"
996 );
997 tokio::time::timeout(
998 std::time::Duration::from_secs(1),
999 terminal.wait_terminated(),
1000 )
1001 .await
1002 .expect("discovery task did not release its generation guard");
1003 }
1004
1005 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1006 async fn cancelled_close_retains_task_and_forbids_restart() {
1007 let directory = tempfile::tempdir().unwrap();
1008 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1009 install_blocked_discovery(&mut source, std::time::Duration::from_millis(250)).await;
1010 let terminal = source.terminal_task_tracker().unwrap();
1011
1012 {
1013 let close =
1014 source.close_until(tokio::time::Instant::now() + std::time::Duration::from_secs(1));
1015 tokio::pin!(close);
1016 tokio::select! {
1017 result = &mut close => panic!("blocked close unexpectedly completed: {result:?}"),
1018 () = tokio::time::sleep(std::time::Duration::from_millis(5)) => {}
1019 }
1020 }
1021
1022 assert!(source.discovery.is_some());
1023 assert!(source.restart_forbidden);
1024 let restart = source
1025 .start(start_request(
1026 text_source_config(directory.path()),
1027 SourcePosition::Initial,
1028 ))
1029 .await;
1030 assert!(matches!(restart, Err(ConnectorError::InvalidState { .. })));
1031 drop(source);
1032 assert!(
1033 !terminal.is_terminated(),
1034 "cancelled close detached an active discovery task"
1035 );
1036 tokio::time::timeout(
1037 std::time::Duration::from_secs(1),
1038 terminal.wait_terminated(),
1039 )
1040 .await
1041 .expect("discovery task did not release its generation guard");
1042 }
1043
1044 #[tokio::test]
1045 async fn test_poll_batch_when_not_started() {
1046 let mut source = FileSource::new();
1047 let result = source.poll_batch(100).await;
1048 assert!(result.is_err());
1049 }
1050
1051 #[test]
1052 fn test_checkpoint_roundtrip() {
1053 let mut source = FileSource::new();
1054 source.manifest.insert("test.csv".into());
1055 let cp = source.checkpoint();
1056 assert!(cp.get_offset("manifest").is_some());
1057 assert_eq!(
1058 cp.get_metadata("connector"),
1059 Some(FILE_CHECKPOINT_CONNECTOR)
1060 );
1061 assert_eq!(
1062 cp.get_metadata(CHECKPOINT_VERSION_METADATA),
1063 Some(FILE_CHECKPOINT_VERSION)
1064 );
1065 }
1066
1067 #[tokio::test]
1068 async fn test_resume_from_checkpoint() {
1069 let directory = tempfile::tempdir().unwrap();
1070 let mut source = FileSource::new();
1071
1072 let mut cp = FileSource::new().checkpoint();
1074 cp.set_offset("manifest", r#"["a.csv"]"#);
1075
1076 let mut config = ConnectorConfig::new("files");
1077 config.set("path", directory.path().to_string_lossy().to_string());
1078 config.set("format", "text");
1079 source
1080 .start(start_request(
1081 config,
1082 SourcePosition::Resume {
1083 attempt: CheckpointAttempt::new(1, 1),
1084 checkpoint: cp,
1085 },
1086 ))
1087 .await
1088 .unwrap();
1089 assert_eq!(source.manifest.processed_count(), 1);
1090 assert!(source.manifest.contains("a.csv"));
1091 source.close().await.unwrap();
1092 }
1093
1094 #[tokio::test]
1095 async fn corrupt_resume_manifest_fails_before_discovery_starts() {
1096 let mut cp = FileSource::new().checkpoint();
1097 cp.set_offset("manifest", "{not-json");
1098 let mut config = ConnectorConfig::new("files");
1099 config.set("path", "/tmp");
1100 config.set("format", "text");
1101 let mut source = FileSource::new();
1102 let error = source
1103 .start(start_request(
1104 config,
1105 SourcePosition::Resume {
1106 attempt: CheckpointAttempt::new(1, 1),
1107 checkpoint: cp,
1108 },
1109 ))
1110 .await
1111 .expect_err("corrupt durable manifest must fail closed");
1112 assert!(error.to_string().contains("invalid file manifest"));
1113 assert!(source.discovery.is_none());
1114 assert!(!source.is_open);
1115 }
1116
1117 #[tokio::test]
1118 async fn resume_rejects_wrong_checkpoint_identity_or_version() {
1119 let directory = tempfile::tempdir().unwrap();
1120 for (connector, version, expected) in [
1121 (
1122 "generator",
1123 FILE_CHECKPOINT_VERSION,
1124 "belongs to connector 'generator'",
1125 ),
1126 (
1127 FILE_CHECKPOINT_CONNECTOR,
1128 "0",
1129 "requires checkpoint.version=1",
1130 ),
1131 ] {
1132 let mut checkpoint = SourceCheckpoint::new();
1133 checkpoint.set_offset("manifest", "[]");
1134 checkpoint.set_metadata("connector", connector);
1135 checkpoint.set_metadata(CHECKPOINT_VERSION_METADATA, version);
1136
1137 let mut source = FileSource::new();
1138 let error = source
1139 .start(start_request(
1140 text_source_config(directory.path()),
1141 SourcePosition::Resume {
1142 attempt: CheckpointAttempt::canonical(7),
1143 checkpoint,
1144 },
1145 ))
1146 .await
1147 .expect_err("non-current file checkpoint must be rejected");
1148 assert!(error.to_string().contains(expected), "{error}");
1149 assert!(source.discovery.is_none());
1150 assert!(!source.is_open);
1151 }
1152 }
1153
1154 #[tokio::test]
1155 async fn resume_rejects_unknown_file_progress_fields() {
1156 let directory = tempfile::tempdir().unwrap();
1157 let mut checkpoint = FileSource::new().checkpoint();
1158 checkpoint.set_offset(
1159 "file_progress",
1160 r#"{"path":"a.txt","size":1,"modified_ms":1,"content_sha256":"00","next_row":1,"legacy_cursor":true}"#,
1161 );
1162 let mut source = FileSource::new();
1163 let error = source
1164 .start(start_request(
1165 text_source_config(directory.path()),
1166 SourcePosition::Resume {
1167 attempt: CheckpointAttempt::canonical(8),
1168 checkpoint,
1169 },
1170 ))
1171 .await
1172 .expect_err("same-version unknown progress fields must fail closed");
1173 assert!(error.to_string().contains("unknown field"), "{error}");
1174 assert!(source.discovery.is_none());
1175 assert!(!source.is_open);
1176 }
1177
1178 #[tokio::test]
1179 async fn resume_rejects_remote_progress_before_filesystem_access() {
1180 let directory = tempfile::tempdir().unwrap();
1181 let mut checkpoint = FileSource::new().checkpoint();
1182 checkpoint.set_offset(
1183 "file_progress",
1184 r#"{"path":"az://container/path?sig=do-not-echo","size":1,"modified_ms":1,"content_sha256":"00","next_row":1}"#,
1185 );
1186 let mut source = FileSource::new();
1187 let error = source
1188 .start(start_request(
1189 text_source_config(directory.path()),
1190 SourcePosition::Resume {
1191 attempt: CheckpointAttempt::canonical(9),
1192 checkpoint,
1193 },
1194 ))
1195 .await
1196 .expect_err("remote checkpoint progress must fail before filesystem access");
1197 let message = error.to_string();
1198 assert!(message.contains("query parameters"), "{message}");
1199 assert!(!message.contains("do-not-echo"), "{message}");
1200 assert!(source.discovery.is_none());
1201 assert!(!source.is_open);
1202 }
1203
1204 #[tokio::test]
1205 async fn cancelled_poll_retains_unpublished_file_progress() {
1206 let directory = tempfile::tempdir().unwrap();
1207 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1208 let first_path = "staged-first.txt";
1209 let second_path = "staged-second.txt";
1210 let first = b"first\n".to_vec();
1211 let second = b"second\n".to_vec();
1212 let reader = Arc::new(TestFileReader {
1213 files: BTreeMap::from([
1214 (first_path.into(), first.clone()),
1215 (second_path.into(), second.clone()),
1216 ]),
1217 blocked_path: Some(second_path.into()),
1218 read_started: Notify::new(),
1219 release_read: Notify::new(),
1220 });
1221 source.reader = reader.clone();
1222 source.pending_files.push_back(staged(first_path, &first));
1223 source.pending_files.push_back(staged(second_path, &second));
1224
1225 let batch = source.poll_batch(10).await.unwrap().unwrap();
1226 assert_eq!(batch.num_rows(), 1);
1227 assert!(source.manifest.contains(first_path));
1228
1229 {
1230 let poll = source.poll_batch(10);
1231 tokio::pin!(poll);
1232 let read_started = reader.read_started.notified();
1233 tokio::pin!(read_started);
1234 tokio::select! {
1235 biased;
1236 result = &mut poll => panic!("blocked read unexpectedly completed: {result:?}"),
1237 () = &mut read_started => {}
1238 }
1239 }
1242
1243 assert!(source.manifest.contains(first_path));
1244 assert!(!source.manifest.contains(second_path));
1245 assert_eq!(
1246 source
1247 .pending_files
1248 .front()
1249 .map(|pending| pending.discovered.path.as_str()),
1250 Some(second_path)
1251 );
1252 source.close().await.unwrap();
1253 }
1254
1255 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1256 async fn retired_source_waits_for_inflight_file_read() {
1257 let directory = tempfile::tempdir().unwrap();
1258 let path = "blocked.txt";
1259 let bytes = b"blocked\n".to_vec();
1260 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1261 let (release_tx, release_rx) = std::sync::mpsc::channel();
1262 let reader = Arc::new(BlockingChildFileReader {
1263 bytes: bytes.clone(),
1264 started: std::sync::Mutex::new(Some(started_tx)),
1265 release: std::sync::Mutex::new(Some(release_rx)),
1266 });
1267
1268 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1269 let terminal = source
1270 .terminal_task_tracker()
1271 .expect("file source owns discovery and read tasks");
1272 source.reader = reader;
1273 source.pending_files.push_back(staged(path, &bytes));
1274
1275 let caller = tokio::spawn(async move { source.poll_batch(10).await });
1276 started_rx.await.unwrap();
1277 caller.abort();
1278 let _ = caller.await;
1279 assert!(
1280 !terminal.is_terminated(),
1281 "retirement must retain the started filesystem child"
1282 );
1283
1284 release_tx.send(()).unwrap();
1285 tokio::time::timeout(
1286 std::time::Duration::from_secs(1),
1287 terminal.wait_terminated(),
1288 )
1289 .await
1290 .expect("file source generation did not reach terminal completion");
1291 }
1292
1293 #[tokio::test]
1294 async fn max_records_cursor_resumes_exactly_within_a_file() {
1295 let directory = tempfile::tempdir().unwrap();
1296 let path = "bounded.txt";
1297 let bytes = b"one\ntwo\nthree\n".to_vec();
1298 let reader = Arc::new(TestFileReader {
1299 files: BTreeMap::from([(path.into(), bytes.clone())]),
1300 blocked_path: None,
1301 read_started: Notify::new(),
1302 release_read: Notify::new(),
1303 });
1304
1305 let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1306 source.reader = reader.clone();
1307 source.pending_files.push_back(staged(path, &bytes));
1308 let first = source.poll_batch(2).await.unwrap().unwrap();
1309 assert_eq!(first.num_rows(), 2);
1310 assert!(!source.manifest.contains(path));
1311 let checkpoint = source.checkpoint();
1312 let progress: FileProgress = serde_json::from_str(
1313 checkpoint
1314 .get_offset("file_progress")
1315 .expect("partial file cursor must be checkpointed"),
1316 )
1317 .unwrap();
1318 assert_eq!(progress.next_row, 2);
1319 source.close().await.unwrap();
1320
1321 let mut resumed = started_text_source(
1322 directory.path(),
1323 SourcePosition::Resume {
1324 attempt: CheckpointAttempt::canonical(11),
1325 checkpoint,
1326 },
1327 )
1328 .await;
1329 resumed.reader = reader;
1330 let remaining = resumed.poll_batch(2).await.unwrap().unwrap();
1331 assert_eq!(remaining.num_rows(), 1);
1332 assert!(resumed.manifest.contains(path));
1333 assert!(resumed.checkpoint().get_offset("file_progress").is_none());
1334 resumed.close().await.unwrap();
1335 }
1336
1337 #[test]
1338 fn test_source_contract() {
1339 let source = FileSource::new();
1340 let contract = source
1341 .contract(&ConnectorConfig::new("files"))
1342 .expect("static file contract");
1343 assert_eq!(contract.consistency, SourceConsistency::Replayable);
1344 assert_eq!(contract.topology, SourceTopology::Singleton);
1345 }
1346}