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