Skip to main content

laminar_connectors/files/
source.rs

1//! File source connector implementing [`SourceConnector`].
2//!
3//! Watches a local directory for new files, decodes them using the
4//! configured format (CSV, JSON, text, Parquet), and produces `RecordBatch`es.
5
6use 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
76/// AutoLoader-style file source connector.
77///
78/// Watches a local directory for new files, infers schema if
79/// needed, and produces `RecordBatch`es via `poll_batch()`.
80pub struct FileSource {
81    /// Parsed configuration.
82    config: Option<FileSourceConfig>,
83    /// Output Arrow schema (resolved in `start()`).
84    schema: SchemaRef,
85    /// Format decoder (created in `start()`).
86    decoder: Option<Box<dyn FormatDecoder>>,
87    /// File discovery engine (started in `start()` after cursor validation).
88    discovery: Option<FileDiscoveryEngine>,
89    /// File ingestion manifest (tracks processed files).
90    manifest: FileIngestionManifest,
91    /// Discovered files staged in connector-owned memory. The front entry is
92    /// retained across cancellation until it has been published or rejected.
93    pending_files: VecDeque<PendingFile>,
94    /// Decoded file being emitted in `max_records`-bounded slices.
95    current_file: Option<DecodedFile>,
96    /// File bytes provider. Local filesystem I/O in production; injectable in tests.
97    reader: Arc<dyn FileReader>,
98    /// Whether the connector has started.
99    is_open: bool,
100    /// A failed, timed-out, or cancelled shutdown permanently retires this instance.
101    restart_forbidden: bool,
102    /// Admission authority for background and blocking work owned by this generation.
103    task_owner: ConnectorTaskOwner,
104    /// Terminal observer handed to the runtime before this generation is dropped.
105    task_tracker: ConnectorTaskTracker,
106}
107
108impl FileSource {
109    /// Creates a new file source with a placeholder schema.
110    #[must_use]
111    pub fn new() -> Self {
112        Self::with_registry(None)
113    }
114
115    /// Creates a new file source with an optional Prometheus registry.
116    #[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        // This latch deliberately flips before the await: cancellation of the
149        // close future must not make the partially closed instance reusable.
150        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        // Replayability is backed by an exact processed-path inventory plus an
192        // exact file/row cursor for a partially emitted file.
193        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        // Decode and validate the durable manifest before discovery can observe a
215        // single path. A corrupt engine checkpoint is fatal: starting from an empty
216        // manifest would rediscover and duplicate every previously ingested file.
217        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        // The file source is local-only; reject unsupported paths before
256        // discovery or reads can start.
257        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        // Resolve format (explicit or auto-detect from path).
265        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        // Build decoder and resolve schema.
275        let (decoder, schema) = build_decoder_and_schema(format, &src_config, &config)?;
276
277        // Optionally append _metadata struct column.
278        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        // Start discovery engine with a snapshot of the current manifest for dedup.
304        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                // No await is permitted between advancing the row/file cursor
390                // above and returning the corresponding zero-copy slice.
391                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            // Loop once without awaiting to publish the first bounded slice.
513        }
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
547// ── Helpers ──────────────────────────────────────────────────────────
548
549fn 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            // For Parquet, schema comes from the file footer (authoritative).
582            // Use a placeholder schema; it will be refined on the first file read.
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 decoder = crate::schema::parquet::ParquetDecoder::new(schema.clone());
587            Ok((Box::new(decoder), schema))
588        }
589        FileFormat::ArrowIpc => {
590            // Arrow IPC files embed their schema in the header — use it as
591            // authoritative. The DDL schema is used as placeholder until
592            // the first file is read.
593            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    // Cloud paths are rejected at `start()`; this path is local-only.
621    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    // Append struct column to batch.
677    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        // Build a checkpoint with manifest data.
1034        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            // Dropping `poll` here models the runtime cancelling an in-flight
1114            // source poll to service shutdown/control work.
1115        }
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}