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, 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
103/// AutoLoader-style file source connector.
104///
105/// Watches a local directory for new files, infers schema if
106/// needed, and produces `RecordBatch`es via `poll_batch()`.
107pub struct FileSource {
108    /// Parsed configuration.
109    config: Option<FileSourceConfig>,
110    /// Output Arrow schema (resolved in `start()`).
111    schema: SchemaRef,
112    /// Format decoder (created in `start()`).
113    decoder: Option<Box<dyn FormatDecoder>>,
114    /// File discovery engine (started in `start()` after cursor validation).
115    discovery: Option<FileDiscoveryEngine>,
116    /// File ingestion manifest (tracks processed files).
117    manifest: FileIngestionManifest,
118    /// Discovered files staged in connector-owned memory. The front entry is
119    /// retained across cancellation until it has been published or rejected.
120    pending_files: VecDeque<PendingFile>,
121    /// Decoded file being emitted in `max_records`-bounded slices.
122    current_file: Option<DecodedFile>,
123    /// File bytes provider. Local filesystem I/O in production; injectable in tests.
124    reader: Arc<dyn FileReader>,
125    /// Whether the connector has started.
126    is_open: bool,
127    /// A failed, timed-out, or cancelled shutdown permanently retires this instance.
128    restart_forbidden: bool,
129    /// Admission authority for background and blocking work owned by this generation.
130    task_owner: ConnectorTaskOwner,
131    /// Terminal observer handed to the runtime before this generation is dropped.
132    task_tracker: ConnectorTaskTracker,
133}
134
135impl FileSource {
136    /// Creates a new file source with a placeholder schema.
137    #[must_use]
138    pub fn new() -> Self {
139        Self::with_registry(None)
140    }
141
142    /// Creates a new file source with an optional Prometheus registry.
143    #[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        // This latch deliberately flips before the await: cancellation of the
176        // close future must not make the partially closed instance reusable.
177        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        // Decode and validate the durable manifest before discovery can observe a
233        // single path. A corrupt engine checkpoint is fatal: starting from an empty
234        // manifest would rediscover and duplicate every previously ingested file.
235        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        // Resolve format (explicit or auto-detect from path).
280        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        // Build decoder and resolve schema.
290        let (decoder, schema) = build_decoder_and_schema(format, &src_config, &config)?;
291
292        // Optionally append _metadata struct column.
293        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        // Start discovery engine with a snapshot of the current manifest for dedup.
319        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                // No await is permitted between advancing the row/file cursor
405                // above and returning the corresponding zero-copy slice.
406                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            // Loop once without awaiting to publish the first bounded slice.
528        }
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        // Replayability is backed by an exact processed-path inventory plus an
565        // exact file/row cursor for a partially emitted file.
566        Ok(SourceContract::new(
567            SourceConsistency::Replayable,
568            SourceTopology::Singleton,
569            SourceInputMode::AppendOnly,
570        ))
571    }
572}
573
574// ── Helpers ──────────────────────────────────────────────────────────
575
576fn 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            // For Parquet, schema comes from the file footer (authoritative).
609            // Use a placeholder schema; it will be refined on the first file read.
610            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            // Arrow IPC files embed their schema in the header — use it as
618            // authoritative. The DDL schema is used as placeholder until
619            // the first file is read.
620            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    // Append struct column to batch.
691    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        // Build a checkpoint with manifest data.
1073        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            // Dropping `poll` here models the runtime cancelling an in-flight
1240            // source poll to service shutdown/control work.
1241        }
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}