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::json_documents::JsonDocumentsDecoder;
28use super::manifest::FileIngestionManifest;
29use super::text_decoder::TextLineDecoder;
30
31const DISCOVERY_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
32const FILE_CHECKPOINT_CONNECTOR: &str = "files";
33const CHECKPOINT_VERSION_METADATA: &str = "checkpoint.version";
34const FILE_CHECKPOINT_VERSION: &str = "1";
35
36fn validate_file_checkpoint(checkpoint: &SourceCheckpoint) -> Result<(), ConnectorError> {
37    match checkpoint.get_metadata("connector") {
38        Some(FILE_CHECKPOINT_CONNECTOR) => {}
39        Some(connector) => {
40            return Err(ConnectorError::ConfigurationError(format!(
41                "file checkpoint belongs to connector '{connector}'"
42            )));
43        }
44        None => {
45            return Err(ConnectorError::ConfigurationError(
46                "file checkpoint is missing connector identity".into(),
47            ));
48        }
49    }
50    if checkpoint.get_metadata(CHECKPOINT_VERSION_METADATA) != Some(FILE_CHECKPOINT_VERSION) {
51        return Err(ConnectorError::ConfigurationError(format!(
52            "file checkpoint requires {CHECKPOINT_VERSION_METADATA}={FILE_CHECKPOINT_VERSION}"
53        )));
54    }
55    Ok(())
56}
57
58#[async_trait]
59trait FileReader: Send + Sync {
60    async fn read(
61        &self,
62        path: &str,
63        task_guard: ConnectorTaskGuard,
64    ) -> Result<Vec<u8>, ConnectorError>;
65}
66
67struct LocalFileReader;
68
69#[async_trait]
70impl FileReader for LocalFileReader {
71    async fn read(
72        &self,
73        path: &str,
74        task_guard: ConnectorTaskGuard,
75    ) -> Result<Vec<u8>, ConnectorError> {
76        read_file_bytes(path, task_guard).await
77    }
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81#[serde(deny_unknown_fields)]
82struct FileProgress {
83    path: String,
84    size: u64,
85    modified_ms: u64,
86    content_sha256: String,
87    next_row: u64,
88}
89
90#[derive(Debug, Clone)]
91struct PendingFile {
92    discovered: DiscoveredFile,
93    resume: Option<FileProgress>,
94}
95
96#[derive(Debug)]
97struct DecodedFile {
98    discovered: DiscoveredFile,
99    content_sha256: String,
100    records: RecordBatch,
101    next_row: usize,
102}
103
104/// AutoLoader-style file source connector.
105///
106/// Watches a local directory for new files, infers schema if
107/// needed, and produces `RecordBatch`es via `poll_batch()`.
108pub struct FileSource {
109    /// Parsed configuration.
110    config: Option<FileSourceConfig>,
111    /// Output Arrow schema (resolved in `start()`).
112    schema: SchemaRef,
113    schema_contract: Option<String>,
114    /// Format decoder (created in `start()`); shared with the blocking decode task.
115    decoder: Option<Arc<dyn FormatDecoder>>,
116    /// Registry for decoder metrics.
117    #[cfg_attr(not(feature = "fpml"), allow(dead_code))]
118    registry: Option<prometheus::Registry>,
119    /// File discovery engine (started in `start()` after cursor validation).
120    discovery: Option<FileDiscoveryEngine>,
121    /// File ingestion manifest (tracks processed files).
122    manifest: FileIngestionManifest,
123    /// Discovered files staged in connector-owned memory. The front entry is
124    /// retained across cancellation until it has been published or rejected.
125    pending_files: VecDeque<PendingFile>,
126    /// Decoded file being emitted in `max_records`-bounded slices.
127    current_file: Option<DecodedFile>,
128    /// File bytes provider. Local filesystem I/O in production; injectable in tests.
129    reader: Arc<dyn FileReader>,
130    /// Whether the connector has started.
131    is_open: bool,
132    /// A failed, timed-out, or cancelled shutdown permanently retires this instance.
133    restart_forbidden: bool,
134    /// Admission authority for background and blocking work owned by this generation.
135    task_owner: ConnectorTaskOwner,
136    /// Terminal observer handed to the runtime before this generation is dropped.
137    task_tracker: ConnectorTaskTracker,
138}
139
140impl FileSource {
141    /// Creates a new file source with a placeholder schema.
142    #[must_use]
143    pub fn new() -> Self {
144        Self::with_registry(None)
145    }
146
147    /// Creates a new file source with an optional Prometheus registry.
148    #[must_use]
149    pub fn with_registry(registry: Option<&prometheus::Registry>) -> Self {
150        let empty_schema = Arc::new(Schema::empty());
151        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
152        Self {
153            config: None,
154            schema: empty_schema,
155            schema_contract: None,
156            decoder: None,
157            registry: registry.cloned(),
158            discovery: None,
159            manifest: FileIngestionManifest::new(),
160            pending_files: VecDeque::new(),
161            current_file: None,
162            reader: Arc::new(LocalFileReader),
163            is_open: false,
164            restart_forbidden: false,
165            task_owner,
166            task_tracker,
167        }
168    }
169
170    async fn close_until(&mut self, deadline: tokio::time::Instant) -> Result<(), ConnectorError> {
171        let Some(discovery) = self.discovery.as_mut() else {
172            if self.is_open {
173                self.restart_forbidden = true;
174                return Err(ConnectorError::InvalidState {
175                    expected: "running discovery task".into(),
176                    actual: "open source without discovery task".into(),
177                });
178            }
179            return Ok(());
180        };
181
182        // This latch deliberately flips before the await: cancellation of the
183        // close future must not make the partially closed instance reusable.
184        let was_restart_forbidden = self.restart_forbidden;
185        self.restart_forbidden = true;
186        discovery.abort_and_join_until(deadline).await?;
187
188        self.discovery = None;
189        self.decoder = None;
190        self.pending_files.clear();
191        self.current_file = None;
192        self.is_open = false;
193        self.restart_forbidden = was_restart_forbidden;
194        info!("file source closed");
195        Ok(())
196    }
197}
198
199impl Default for FileSource {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205impl std::fmt::Debug for FileSource {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        f.debug_struct("FileSource")
208            .field("is_open", &self.is_open)
209            .field("restart_forbidden", &self.restart_forbidden)
210            .field("schema_fields", &self.schema.fields().len())
211            .field("manifest_count", &self.manifest.processed_count())
212            .field("pending_files", &self.pending_files.len())
213            .field("has_current_file", &self.current_file.is_some())
214            .finish()
215    }
216}
217
218#[async_trait]
219impl SourceConnector for FileSource {
220    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
221        Some(self.task_tracker.clone())
222    }
223
224    async fn resolve_schema(
225        &mut self,
226        config: &ConnectorConfig,
227        explicit: Option<SchemaRef>,
228    ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
229        super::schema_resolution::resolve(config, explicit).await
230    }
231
232    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
233        if self.is_open || self.discovery.is_some() || self.restart_forbidden {
234            return Err(ConnectorError::InvalidState {
235                expected: "new or cleanly closed file source".into(),
236                actual: if self.restart_forbidden {
237                    "retired file source generation".into()
238                } else {
239                    "file source already open".into()
240                },
241            });
242        }
243
244        let (mut config, position, _) = request.into_parts();
245        let saved = match &position {
246            SourcePosition::Resume { checkpoint, .. } => checkpoint
247                .get_metadata("schema.contract")
248                .map(str::to_owned),
249            _ => None,
250        };
251        let resumed = !matches!(position, SourcePosition::Initial);
252        #[cfg_attr(not(feature = "fpml"), allow(unused_mut))]
253        let mut src_config = FileSourceConfig::from_connector_config(&config)?;
254        // Validate cursor evidence before metadata I/O. Never repair corrupt progress by discovery.
255        let (manifest, progress) = restore_ingestion_position(position)?;
256        self.prepare_reader_contract(&mut config, saved, resumed, &src_config)
257            .await?;
258        let format = src_config
259            .format
260            .or_else(|| FileFormat::from_extension(&src_config.path))
261            .ok_or_else(|| {
262                ConnectorError::ConfigurationError(
263                    "cannot detect file format; specify format explicitly".into(),
264                )
265            })?;
266        let committed = config
267            .schema_binding()
268            .map(|binding| Arc::new(binding.logical.clone()));
269        if let Some(schema) = &committed {
270            let payload = if src_config.include_metadata {
271                Arc::new(
272                    schema
273                        .project(&(0..schema.fields().len().saturating_sub(1)).collect::<Vec<_>>())
274                        .map_err(|error| ConnectorError::SchemaMismatch(error.to_string()))?,
275                )
276            } else {
277                Arc::clone(schema)
278            };
279            config.set(
280                "_arrow_schema",
281                crate::config::encode_arrow_schema_ipc(&payload),
282            );
283        }
284        // Build decoder and resolve schema.
285        let (decoder, schema) = match format {
286            #[cfg(feature = "fpml")]
287            FileFormat::Fpml => {
288                let guard =
289                    self.task_owner
290                        .track()
291                        .ok_or_else(|| ConnectorError::InvalidState {
292                            expected: "live file task owner".into(),
293                            actual: "retired".into(),
294                        })?;
295                start_fpml_decoder(&mut src_config, &config, self.registry.clone(), guard).await?
296            }
297            _ => build_decoder_and_schema(format, &src_config, &config)?,
298        };
299
300        let final_schema = match committed {
301            Some(schema) => schema,
302            None => super::schema_resolution::output_schema(&schema, src_config.include_metadata)?,
303        };
304
305        // Start discovery engine with a snapshot of the current manifest for dedup.
306        let discovery_config = DiscoveryConfig {
307            path: src_config.path.clone(),
308            poll_interval: src_config.poll_interval,
309            stabilisation_delay: src_config.stabilisation_delay,
310            glob_pattern: src_config.glob_pattern.clone(),
311        };
312        let known = Arc::new(manifest.snapshot_for_dedup());
313        let discovery_guard = self
314            .task_owner
315            .track()
316            .expect("live file source cannot have a retired task owner");
317        let initial_scan_guard = self
318            .task_owner
319            .track()
320            .expect("live file source cannot have a retired task owner");
321        let discovery = FileDiscoveryEngine::start(
322            discovery_config,
323            known,
324            discovery_guard,
325            initial_scan_guard,
326        );
327
328        self.config = Some(src_config);
329        self.schema = final_schema;
330        self.decoder = Some(decoder);
331        self.discovery = Some(discovery);
332        self.manifest = manifest;
333        self.pending_files.clear();
334        self.current_file = None;
335        if let Some(progress) = progress {
336            self.pending_files.push_back(PendingFile {
337                discovered: DiscoveredFile {
338                    path: progress.path.clone(),
339                    size: progress.size,
340                    modified_ms: progress.modified_ms,
341                },
342                resume: Some(progress),
343            });
344        }
345        self.is_open = true;
346
347        info!(
348            "file source opened: format={format:?}, schema_fields={}",
349            self.schema.fields().len()
350        );
351        Ok(())
352    }
353
354    async fn poll_batch(
355        &mut self,
356        max_records: usize,
357    ) -> Result<Option<SourceBatch>, ConnectorError> {
358        if max_records == 0 {
359            return Err(ConnectorError::ConfigurationError(
360                "file source poll max_records must be greater than zero".into(),
361            ));
362        }
363
364        let config = self
365            .config
366            .clone()
367            .ok_or_else(|| ConnectorError::InvalidState {
368                expected: "started".into(),
369                actual: "closed".into(),
370            })?;
371        if self.decoder.is_none() {
372            return Err(ConnectorError::InvalidState {
373                expected: "decoder ready".into(),
374                actual: "no decoder".into(),
375            });
376        }
377
378        loop {
379            if let Some(mut decoded) = self.current_file.take() {
380                let rows_left = decoded.records.num_rows() - decoded.next_row;
381                let row_count = rows_left.min(max_records);
382                let result = decoded.records.slice(decoded.next_row, row_count);
383                decoded.next_row += row_count;
384
385                if decoded.next_row == decoded.records.num_rows() {
386                    self.manifest.insert(decoded.discovered.path);
387                } else {
388                    self.current_file = Some(decoded);
389                }
390
391                // No await is permitted between advancing the row/file cursor
392                // above and returning the corresponding zero-copy slice.
393                return Ok(Some(SourceBatch::new(result)));
394            }
395
396            if self.pending_files.is_empty() {
397                let discovery =
398                    self.discovery
399                        .as_mut()
400                        .ok_or_else(|| ConnectorError::InvalidState {
401                            expected: "discovery running".into(),
402                            actual: "no discovery".into(),
403                        })?;
404                let discovered = match discovery.drain(config.max_files_per_poll).await {
405                    Ok(discovered) => discovered,
406                    Err(error) => {
407                        self.restart_forbidden = true;
408                        return Err(error);
409                    }
410                };
411                self.pending_files
412                    .extend(discovered.into_iter().map(|discovered| PendingFile {
413                        discovered,
414                        resume: None,
415                    }));
416            }
417
418            let Some(pending) = self.pending_files.front().cloned() else {
419                return Ok(None);
420            };
421
422            if self.manifest.contains(&pending.discovered.path) {
423                self.pending_files.pop_front();
424                continue;
425            }
426
427            let decoded = self.load_file(&pending, &config).await?;
428            self.pending_files.pop_front();
429            if decoded.records.num_rows() == 0 {
430                debug!(
431                    "file source: empty batch from '{}'",
432                    pending.discovered.path
433                );
434                self.manifest.insert(pending.discovered.path);
435                return Ok(None);
436            }
437            self.current_file = Some(decoded);
438            // Loop once without awaiting to publish the first bounded slice.
439        }
440    }
441
442    fn schema(&self) -> SchemaRef {
443        self.schema.clone()
444    }
445
446    fn checkpoint(&self) -> SourceCheckpoint {
447        let mut cp = SourceCheckpoint::new();
448        cp.set_metadata("connector", FILE_CHECKPOINT_CONNECTOR);
449        cp.set_metadata(CHECKPOINT_VERSION_METADATA, FILE_CHECKPOINT_VERSION);
450        if let Some(binding) = &self.schema_contract {
451            cp.set_metadata("schema.contract", binding);
452        }
453        self.manifest.to_checkpoint(&mut cp);
454        if let Some(file) = &self.current_file {
455            let progress = FileProgress {
456                path: file.discovered.path.clone(),
457                size: file.discovered.size,
458                modified_ms: file.discovered.modified_ms,
459                content_sha256: file.content_sha256.clone(),
460                next_row: u64::try_from(file.next_row)
461                    .expect("Arrow record counts are representable as u64"),
462            };
463            cp.set_offset(
464                "file_progress",
465                serde_json::to_string(&progress)
466                    .expect("file progress contains only infallibly serializable fields"),
467            );
468        }
469        cp
470    }
471
472    async fn close(&mut self) -> Result<(), ConnectorError> {
473        self.close_until(tokio::time::Instant::now() + DISCOVERY_CLOSE_TIMEOUT)
474            .await
475    }
476
477    fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
478        // Replayability is backed by an exact processed-path inventory plus an
479        // exact file/row cursor for a partially emitted file.
480        Ok(SourceContract::new(
481            SourceConsistency::Replayable,
482            SourceTopology::Singleton,
483            SourceInputMode::AppendOnly,
484        ))
485    }
486}
487
488impl FileSource {
489    async fn prepare_reader_contract(
490        &mut self,
491        config: &mut ConnectorConfig,
492        saved: Option<String>,
493        resumed: bool,
494        src_config: &FileSourceConfig,
495    ) -> Result<(), ConnectorError> {
496        if let Some(saved) = saved {
497            if saved.len() > laminar_core::schema_binding::MAX_SCHEMA_BINDING_BYTES {
498                return Err(ConnectorError::SchemaMismatch(
499                    "file checkpoint schema exceeds its bound".into(),
500                ));
501            }
502            let binding: crate::schema::resolution::SchemaBinding = serde_json::from_str(&saved)
503                .map_err(|_| {
504                    ConnectorError::SchemaMismatch(
505                        "file checkpoint reader contract is malformed".into(),
506                    )
507                })?;
508            if config
509                .schema_binding()
510                .is_some_and(|committed| committed != &binding)
511            {
512                return Err(ConnectorError::SchemaMismatch(
513                    "file checkpoint belongs to a different schema contract".into(),
514                ));
515            }
516            config.set_schema_binding(binding)?;
517        }
518        if config.schema_binding().is_none() {
519            if resumed
520                && config.arrow_schema().is_none()
521                && src_config.format != Some(FileFormat::Text)
522            {
523                return Err(ConnectorError::SchemaMismatch("legacy file recovery needs explicit fields or a saved reader contract; metadata/inference cannot run on resume".into()));
524            }
525            let binding = self.resolve_schema(config, config.arrow_schema()).await?;
526            config.set_schema_binding(binding)?;
527        }
528        self.schema_contract = config
529            .schema_binding()
530            .map(laminar_core::schema_binding::SchemaBinding::canonical_bytes)
531            .transpose()
532            .map_err(crate::schema::resolution::binding_error)?
533            .map(String::from_utf8)
534            .transpose()
535            .map_err(|_| ConnectorError::Internal("schema encoding is not UTF8".into()))?;
536
537        Ok(())
538    }
539
540    async fn load_file(
541        &mut self,
542        pending: &PendingFile,
543        config: &FileSourceConfig,
544    ) -> Result<DecodedFile, ConnectorError> {
545        if pending.discovered.size > config.max_file_bytes as u64 {
546            return Err(ConnectorError::ConfigurationError(format!(
547                "file '{}' size {} exceeds max_file_bytes {}",
548                pending.discovered.path, pending.discovered.size, config.max_file_bytes
549            )));
550        }
551
552        let read_guard = self
553            .task_owner
554            .track()
555            .ok_or_else(|| ConnectorError::InvalidState {
556                expected: "live file task owner".into(),
557                actual: "retired".into(),
558            })?;
559        let bytes = Arc::clone(&self.reader)
560            .read(&pending.discovered.path, read_guard)
561            .await?;
562        if bytes.len() > config.max_file_bytes {
563            return Err(ConnectorError::ConfigurationError(format!(
564                "file '{}' actual size {} exceeds max_file_bytes {}",
565                pending.discovered.path,
566                bytes.len(),
567                config.max_file_bytes
568            )));
569        }
570        if u64::try_from(bytes.len()).ok() != Some(pending.discovered.size) {
571            return Err(ConnectorError::ReadError(format!(
572                "file '{}' changed size after discovery (expected {}, read {})",
573                pending.discovered.path,
574                pending.discovered.size,
575                bytes.len()
576            )));
577        }
578
579        let content_sha256 = sha256_hex(&bytes);
580        let next_row = if let Some(progress) = &pending.resume {
581            if progress.content_sha256 != content_sha256 {
582                return Err(ConnectorError::ConfigurationError(format!(
583                    "file '{}' changed content since its checkpointed partial read",
584                    pending.discovered.path
585                )));
586            }
587            usize::try_from(progress.next_row).map_err(|_| {
588                ConnectorError::ConfigurationError(format!(
589                    "file '{}' checkpoint row {} exceeds this runtime's address space",
590                    pending.discovered.path, progress.next_row
591                ))
592            })?
593        } else {
594            0
595        };
596
597        let decoder =
598            Arc::clone(
599                self.decoder
600                    .as_ref()
601                    .ok_or_else(|| ConnectorError::InvalidState {
602                        expected: "prepared file decoder".into(),
603                        actual: "missing".into(),
604                    })?,
605            );
606        let decode_guard = self
607            .task_owner
608            .track()
609            .ok_or_else(|| ConnectorError::InvalidState {
610                expected: "live file task owner".into(),
611                actual: "retired".into(),
612            })?;
613        // Decoding (XML Schema validation in particular) is CPU-bound; keep it off the runtime.
614        let mut records = tokio::task::spawn_blocking(move || {
615            let _decode_guard = decode_guard;
616            decoder.decode_batch(&[RawRecord::new(bytes)])
617        })
618        .await
619        .map_err(|error| ConnectorError::ReadError(format!("file decode worker failed: {error}")))?
620        .map_err(ConnectorError::from)?;
621        if config.include_metadata {
622            records = append_metadata_column(
623                &records,
624                &pending.discovered.path,
625                pending.discovered.size,
626                pending.discovered.modified_ms,
627            )?;
628        }
629        records = crate::schema::resolution::project_batch(&records, &self.schema)?;
630        if pending.resume.is_some() && next_row >= records.num_rows() {
631            return Err(ConnectorError::ConfigurationError(format!(
632                "file '{}' checkpoint row {} is outside decoded row count {}",
633                pending.discovered.path,
634                next_row,
635                records.num_rows()
636            )));
637        }
638
639        Ok(DecodedFile {
640            discovered: pending.discovered.clone(),
641            content_sha256,
642            records,
643            next_row,
644        })
645    }
646}
647
648// ── Helpers ──────────────────────────────────────────────────────────
649
650fn build_decoder_and_schema(
651    format: FileFormat,
652    src_config: &FileSourceConfig,
653    connector_config: &ConnectorConfig,
654) -> Result<(Arc<dyn FormatDecoder>, SchemaRef), ConnectorError> {
655    match format {
656        FileFormat::Csv => {
657            let schema = connector_config.arrow_schema().unwrap_or_else(|| {
658                Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
659            });
660            let csv_config = crate::schema::CsvDecoderConfig {
661                delimiter: src_config.csv_delimiter,
662                has_header: src_config.csv_has_header,
663                ..crate::schema::CsvDecoderConfig::default()
664            };
665            let decoder = crate::schema::CsvDecoder::with_config(schema.clone(), csv_config);
666            Ok((Arc::new(decoder), schema))
667        }
668        FileFormat::Json => {
669            let schema = connector_config.arrow_schema().unwrap_or_else(|| {
670                Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
671            });
672            let json_config = crate::schema::JsonDecoderConfig::default();
673            let decoder = crate::schema::JsonDecoder::with_config(schema.clone(), json_config);
674            Ok((Arc::new(JsonDocumentsDecoder::new(decoder)), schema))
675        }
676        FileFormat::Text => {
677            let decoder = TextLineDecoder::new();
678            let schema = decoder.output_schema();
679            Ok((Arc::new(decoder), schema))
680        }
681        FileFormat::Parquet => {
682            // For Parquet, schema comes from the file footer (authoritative).
683            // Use a placeholder schema; it will be refined on the first file read.
684            let schema = connector_config.arrow_schema().unwrap_or_else(|| {
685                Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
686            });
687            let decoder = crate::schema::parquet::ParquetDecoder::new(schema.clone());
688            Ok((Arc::new(decoder), schema))
689        }
690        FileFormat::ArrowIpc => {
691            // Arrow IPC files embed their schema in the header — use it as
692            // authoritative. The DDL schema is used as placeholder until
693            // the first file is read.
694            let schema = connector_config.arrow_schema().unwrap_or_else(|| {
695                Arc::new(Schema::new(vec![Field::new("value", DataType::Utf8, true)]))
696            });
697            let decoder = super::arrow_ipc_codec::ArrowIpcDecoder::new(schema.clone());
698            Ok((Arc::new(decoder), schema))
699        }
700        #[cfg(feature = "fpml")]
701        FileFormat::Fpml => Err(ConnectorError::Internal(
702            "FpML decoders are started asynchronously".into(),
703        )),
704    }
705}
706
707/// Resolve the closure and profile off the runtime, check them against the committed schema
708/// contract, and start the validator worker. Files larger than one document are rejected before
709/// they are read.
710#[cfg(feature = "fpml")]
711async fn start_fpml_decoder(
712    src_config: &mut FileSourceConfig,
713    config: &ConnectorConfig,
714    registry: Option<prometheus::Registry>,
715    guard: ConnectorTaskGuard,
716) -> Result<(Arc<dyn FormatDecoder>, SchemaRef), ConnectorError> {
717    let fpml = crate::fpml::FpmlConfig::from_connector_config(config)?;
718    src_config.max_file_bytes = src_config
719        .max_file_bytes
720        .min(fpml.limits.max_document_bytes);
721    let resolving = fpml.clone();
722    let (artifact, profile) = tokio::task::spawn_blocking(move || resolving.resolve())
723        .await
724        .map_err(|error| {
725            ConnectorError::Internal(format!("FpML profile loader failed: {error}"))
726        })??;
727    if let Some(committed) = config
728        .schema_binding()
729        .and_then(|binding| binding.value.as_ref())
730    {
731        if committed.identity != profile.native_schema(&artifact).identity {
732            return Err(ConnectorError::SchemaMismatch(
733                "the FpML profile or schema closure differs from the one this source was created with; recreate the source to change it".into(),
734            ));
735        }
736    }
737    let source = config.get("laminar.source.name").unwrap_or("files");
738    let decoder = crate::fpml::FpmlDecoder::start(
739        artifact,
740        &profile,
741        &fpml,
742        source,
743        registry.as_ref(),
744        Some(guard),
745    )
746    .await?;
747    Ok((Arc::new(decoder), profile.schema()))
748}
749
750fn sha256_hex(bytes: &[u8]) -> String {
751    use sha2::{Digest, Sha256};
752
753    format!("{:x}", Sha256::digest(bytes))
754}
755
756async fn read_file_bytes(
757    path: &str,
758    task_guard: ConnectorTaskGuard,
759) -> Result<Vec<u8>, ConnectorError> {
760    let read_path = path.to_owned();
761    let error_path = read_path.clone();
762    tokio::task::spawn_blocking(move || {
763        let _task_guard = task_guard;
764        std::fs::read(read_path)
765    })
766    .await
767    .map_err(|e| ConnectorError::ReadError(format!("file read worker failed: {e}")))?
768    .map_err(|e| ConnectorError::ReadError(format!("cannot read file '{error_path}': {e}")))
769}
770
771pub(super) fn append_metadata_column(
772    batch: &RecordBatch,
773    file_path: &str,
774    file_size: u64,
775    modified_ms: u64,
776) -> Result<RecordBatch, ConnectorError> {
777    use arrow_array::{ArrayRef, StringArray, StructArray, UInt64Array};
778
779    let n = batch.num_rows();
780    let file_name = std::path::Path::new(file_path)
781        .file_name()
782        .and_then(|n| n.to_str())
783        .unwrap_or(file_path);
784
785    let path_array: ArrayRef = Arc::new(StringArray::from(vec![file_path; n]));
786    let name_array: ArrayRef = Arc::new(StringArray::from(vec![file_name; n]));
787    let size_array: ArrayRef = Arc::new(UInt64Array::from(vec![file_size; n]));
788    #[allow(clippy::cast_possible_wrap)]
789    let mod_array: ArrayRef = Arc::new(arrow_array::TimestampMillisecondArray::from(vec![
790        modified_ms as i64;
791        n
792    ]));
793
794    let fields = vec![
795        Field::new("file_path", DataType::Utf8, false),
796        Field::new("file_name", DataType::Utf8, false),
797        Field::new("file_size", DataType::UInt64, false),
798        Field::new(
799            "file_modification_time",
800            DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
801            true,
802        ),
803    ];
804    let struct_array = StructArray::try_new(
805        fields.into(),
806        vec![path_array, name_array, size_array, mod_array],
807        None,
808    )
809    .map_err(|e| ConnectorError::ReadError(format!("metadata struct error: {e}")))?;
810
811    // Append struct column to batch.
812    let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
813    columns.push(Arc::new(struct_array));
814
815    let mut fields: Vec<Field> = batch
816        .schema()
817        .fields()
818        .iter()
819        .map(|f| f.as_ref().clone())
820        .collect();
821    fields.push(Field::new(
822        "_metadata",
823        DataType::Struct(
824            vec![
825                Field::new("file_path", DataType::Utf8, false),
826                Field::new("file_name", DataType::Utf8, false),
827                Field::new("file_size", DataType::UInt64, false),
828                Field::new(
829                    "file_modification_time",
830                    DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
831                    true,
832                ),
833            ]
834            .into(),
835        ),
836        false,
837    ));
838
839    RecordBatch::try_new(
840        Arc::new(Schema::new_with_metadata(
841            fields,
842            batch.schema().metadata().clone(),
843        )),
844        columns,
845    )
846    .map_err(|e| ConnectorError::ReadError(format!("metadata append error: {e}")))
847}
848
849fn restore_ingestion_position(
850    position: SourcePosition,
851) -> Result<(FileIngestionManifest, Option<FileProgress>), ConnectorError> {
852    Ok(match position {
853        SourcePosition::Initial => (FileIngestionManifest::new(), None),
854        SourcePosition::Initialized { .. } => {
855            return Err(ConnectorError::ConfigurationError(
856                "file source has no sealed topology startup contract".into(),
857            ));
858        }
859        SourcePosition::Resume {
860            attempt,
861            checkpoint,
862        } => {
863            validate_file_checkpoint(&checkpoint)?;
864            if checkpoint.get_offset("manifest").is_none() {
865                return Err(ConnectorError::ConfigurationError(format!(
866                    "file checkpoint {attempt:?} is missing required manifest state"
867                )));
868            }
869            let manifest = FileIngestionManifest::from_checkpoint(&checkpoint).map_err(|e| {
870                ConnectorError::ConfigurationError(format!(
871                    "invalid file manifest in checkpoint {attempt:?}: {e}"
872                ))
873            })?;
874            let progress = checkpoint
875                .get_offset("file_progress")
876                .map(serde_json::from_str::<FileProgress>)
877                .transpose()
878                .map_err(|e| {
879                    ConnectorError::ConfigurationError(format!(
880                        "invalid file progress in checkpoint {attempt:?}: {e}"
881                    ))
882                })?
883                .map(|mut progress| {
884                    progress.path = local_files_path(&progress.path)?;
885                    Ok::<_, ConnectorError>(progress)
886                })
887                .transpose()?;
888            if progress
889                .as_ref()
890                .is_some_and(|p| manifest.contains(&p.path) || p.next_row == 0)
891            {
892                return Err(ConnectorError::ConfigurationError(format!(
893                    "file checkpoint {attempt:?} contains contradictory progress"
894                )));
895            }
896            (manifest, progress)
897        }
898    })
899}
900
901#[cfg(test)]
902mod tests {
903    use super::*;
904    use crate::connector::{DeliveryGuarantee, SourcePosition, SourceStart};
905    use laminar_core::checkpoint::CheckpointAttempt;
906    use std::collections::BTreeMap;
907    use tokio::sync::Notify;
908
909    struct TestFileReader {
910        files: BTreeMap<String, Vec<u8>>,
911        blocked_path: Option<String>,
912        read_started: Notify,
913        release_read: Notify,
914    }
915
916    struct BlockingChildFileReader {
917        bytes: Vec<u8>,
918        started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
919        release: std::sync::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
920    }
921
922    #[async_trait]
923    impl FileReader for TestFileReader {
924        async fn read(
925            &self,
926            path: &str,
927            _task_guard: ConnectorTaskGuard,
928        ) -> Result<Vec<u8>, ConnectorError> {
929            if self.blocked_path.as_deref() == Some(path) {
930                self.read_started.notify_one();
931                self.release_read.notified().await;
932            }
933            self.files
934                .get(path)
935                .cloned()
936                .ok_or_else(|| ConnectorError::ReadError(format!("missing test file '{path}'")))
937        }
938    }
939
940    #[async_trait]
941    impl FileReader for BlockingChildFileReader {
942        async fn read(
943            &self,
944            _path: &str,
945            task_guard: ConnectorTaskGuard,
946        ) -> Result<Vec<u8>, ConnectorError> {
947            let started = self.started.lock().unwrap().take().unwrap();
948            let release = self.release.lock().unwrap().take().unwrap();
949            let bytes = self.bytes.clone();
950            tokio::task::spawn_blocking(move || {
951                let _task_guard = task_guard;
952                let _ = started.send(());
953                let _ = release.recv();
954                bytes
955            })
956            .await
957            .map_err(|e| ConnectorError::ReadError(format!("test file worker failed: {e}")))
958        }
959    }
960
961    fn start_request(config: ConnectorConfig, position: SourcePosition) -> SourceStart {
962        SourceStart::new(config, position, DeliveryGuarantee::AtLeastOnce).unwrap()
963    }
964
965    async fn started_text_source(
966        directory: &std::path::Path,
967        position: SourcePosition,
968    ) -> FileSource {
969        let config = text_source_config(directory);
970        let mut source = FileSource::new();
971        source.start(start_request(config, position)).await.unwrap();
972        source
973    }
974
975    #[cfg(unix)]
976    #[tokio::test]
977    async fn local_path_with_url_like_component_can_be_read() {
978        let directory = tempfile::tempdir().unwrap();
979        let nested = directory.path().join("url:").join("nested");
980        std::fs::create_dir_all(&nested).unwrap();
981        std::fs::write(nested.join("data.txt"), b"local").unwrap();
982        let path = format!("{}/url://nested/data.txt", directory.path().display());
983        let (owner, _tracker) = ConnectorTaskOwner::new();
984
985        let bytes = read_file_bytes(&path, owner.track().unwrap())
986            .await
987            .unwrap();
988
989        assert_eq!(bytes, b"local");
990    }
991
992    fn text_source_config(directory: &std::path::Path) -> ConnectorConfig {
993        let mut config = ConnectorConfig::new("files");
994        config.set("path", directory.to_string_lossy().to_string());
995        config.set("format", "text");
996        config.set("stabilisation_delay", "60s");
997        config
998    }
999
1000    async fn install_blocked_discovery(source: &mut FileSource, blocked_for: std::time::Duration) {
1001        source
1002            .discovery
1003            .as_mut()
1004            .unwrap()
1005            .abort_and_join_until(tokio::time::Instant::now() + std::time::Duration::from_secs(1))
1006            .await
1007            .unwrap();
1008        let guard = source.task_owner.track().unwrap();
1009        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1010        let handle = tokio::spawn(async move {
1011            let _guard = guard;
1012            let _ = started_tx.send(());
1013            std::thread::sleep(blocked_for);
1014            Ok(())
1015        });
1016        source
1017            .discovery
1018            .as_mut()
1019            .unwrap()
1020            .install_task_for_test(handle);
1021        started_rx.await.unwrap();
1022    }
1023
1024    fn staged(path: &str, bytes: &[u8]) -> PendingFile {
1025        PendingFile {
1026            discovered: DiscoveredFile {
1027                path: path.into(),
1028                size: u64::try_from(bytes.len()).unwrap(),
1029                modified_ms: 1234,
1030            },
1031            resume: None,
1032        }
1033    }
1034
1035    #[test]
1036    fn json_file_decoder_reads_one_row_per_line() {
1037        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
1038        let mut config = ConnectorConfig::new("files");
1039        config.set("path", "/data/in");
1040        config.set("format", "json");
1041        config.set(
1042            "_arrow_schema",
1043            crate::config::encode_arrow_schema_ipc(&schema),
1044        );
1045        let src_config = FileSourceConfig::from_connector_config(&config).unwrap();
1046        let (decoder, _) =
1047            build_decoder_and_schema(FileFormat::Json, &src_config, &config).unwrap();
1048
1049        let batch = decoder
1050            .decode_batch(&[RawRecord::new(b"{\"id\":1}\n{\"id\":2}\n".to_vec())])
1051            .unwrap();
1052
1053        assert_eq!(batch.num_rows(), 2);
1054    }
1055
1056    #[test]
1057    fn test_file_source_default() {
1058        let source = FileSource::new();
1059        assert!(!source.is_open);
1060        assert_eq!(source.manifest.processed_count(), 0);
1061    }
1062
1063    #[tokio::test]
1064    async fn test_open_missing_path() {
1065        let mut source = FileSource::new();
1066        let config = ConnectorConfig::new("files");
1067        let result = source
1068            .start(start_request(config, SourcePosition::Initial))
1069            .await;
1070        assert!(result.is_err());
1071    }
1072
1073    #[tokio::test]
1074    async fn test_open_with_text_format() {
1075        let directory = tempfile::tempdir().unwrap();
1076        let mut source = FileSource::new();
1077        let mut config = ConnectorConfig::new("files");
1078        config.set("path", directory.path().to_string_lossy().to_string());
1079        config.set("format", "text");
1080        let result = source
1081            .start(start_request(config, SourcePosition::Initial))
1082            .await;
1083        assert!(result.is_ok());
1084        assert!(source.is_open);
1085        assert_eq!(source.schema().field(0).name(), "line");
1086        source.close().await.unwrap();
1087    }
1088
1089    #[tokio::test]
1090    async fn start_while_open_is_rejected_before_request_validation() {
1091        let directory = tempfile::tempdir().unwrap();
1092        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1093        let original_path = source.config.as_ref().unwrap().path.clone();
1094
1095        let error = source
1096            .start(start_request(
1097                ConnectorConfig::new("files"),
1098                SourcePosition::Initial,
1099            ))
1100            .await
1101            .expect_err("an open source must reject a second start");
1102        assert!(matches!(error, ConnectorError::InvalidState { .. }));
1103        assert_eq!(source.config.as_ref().unwrap().path, original_path);
1104        assert!(source.is_open);
1105        source.close().await.unwrap();
1106    }
1107
1108    #[tokio::test]
1109    async fn discovery_failure_reaches_poll_and_retires_the_instance() {
1110        let directory = tempfile::tempdir().unwrap();
1111        let missing = directory.path().join("missing");
1112        let mut source = FileSource::new();
1113        source
1114            .start(start_request(
1115                text_source_config(&missing),
1116                SourcePosition::Initial,
1117            ))
1118            .await
1119            .unwrap();
1120
1121        let error = tokio::time::timeout(std::time::Duration::from_secs(1), async {
1122            loop {
1123                match source.poll_batch(10).await {
1124                    Ok(None) => tokio::task::yield_now().await,
1125                    Ok(Some(batch)) => panic!("unexpected batch with {} rows", batch.num_rows()),
1126                    Err(error) => break error,
1127                }
1128            }
1129        })
1130        .await
1131        .expect("discovery failure remained silent");
1132        assert!(matches!(error, ConnectorError::InvalidState { .. }));
1133        assert!(!error.is_transient());
1134        assert!(error.to_string().contains("is not a directory"));
1135        assert!(source.restart_forbidden);
1136
1137        let retry_directory = tempfile::tempdir().unwrap();
1138        let retry_error = source
1139            .start(start_request(
1140                text_source_config(retry_directory.path()),
1141                SourcePosition::Initial,
1142            ))
1143            .await
1144            .expect_err("failed source instance must not restart");
1145        assert!(matches!(retry_error, ConnectorError::InvalidState { .. }));
1146    }
1147
1148    #[tokio::test]
1149    async fn clean_close_allows_same_instance_restart() {
1150        let first = tempfile::tempdir().unwrap();
1151        let second = tempfile::tempdir().unwrap();
1152        let mut source = started_text_source(first.path(), SourcePosition::Initial).await;
1153        source.close().await.unwrap();
1154
1155        source
1156            .start(start_request(
1157                text_source_config(second.path()),
1158                SourcePosition::Initial,
1159            ))
1160            .await
1161            .unwrap();
1162        assert!(source.is_open);
1163        assert_eq!(
1164            source.config.as_ref().unwrap().path.as_str(),
1165            second.path().to_string_lossy().as_ref()
1166        );
1167        source.close().await.unwrap();
1168    }
1169
1170    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1171    async fn timed_out_close_retains_task_and_forbids_restart() {
1172        let directory = tempfile::tempdir().unwrap();
1173        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1174        install_blocked_discovery(&mut source, std::time::Duration::from_millis(250)).await;
1175        let terminal = source.terminal_task_tracker().unwrap();
1176
1177        let error = source
1178            .close_until(tokio::time::Instant::now() + std::time::Duration::from_millis(5))
1179            .await
1180            .expect_err("blocked discovery must exceed the close deadline");
1181        assert!(matches!(error, ConnectorError::Timeout(_)));
1182        assert!(source.discovery.is_some());
1183        assert!(source.restart_forbidden);
1184
1185        let restart = source
1186            .start(start_request(
1187                text_source_config(directory.path()),
1188                SourcePosition::Initial,
1189            ))
1190            .await;
1191        assert!(matches!(restart, Err(ConnectorError::InvalidState { .. })));
1192        drop(source);
1193        assert!(
1194            !terminal.is_terminated(),
1195            "timed-out close detached an active discovery task"
1196        );
1197        tokio::time::timeout(
1198            std::time::Duration::from_secs(1),
1199            terminal.wait_terminated(),
1200        )
1201        .await
1202        .expect("discovery task did not release its generation guard");
1203    }
1204
1205    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1206    async fn cancelled_close_retains_task_and_forbids_restart() {
1207        let directory = tempfile::tempdir().unwrap();
1208        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1209        install_blocked_discovery(&mut source, std::time::Duration::from_millis(250)).await;
1210        let terminal = source.terminal_task_tracker().unwrap();
1211
1212        {
1213            let close =
1214                source.close_until(tokio::time::Instant::now() + std::time::Duration::from_secs(1));
1215            tokio::pin!(close);
1216            tokio::select! {
1217                result = &mut close => panic!("blocked close unexpectedly completed: {result:?}"),
1218                () = tokio::time::sleep(std::time::Duration::from_millis(5)) => {}
1219            }
1220        }
1221
1222        assert!(source.discovery.is_some());
1223        assert!(source.restart_forbidden);
1224        let restart = source
1225            .start(start_request(
1226                text_source_config(directory.path()),
1227                SourcePosition::Initial,
1228            ))
1229            .await;
1230        assert!(matches!(restart, Err(ConnectorError::InvalidState { .. })));
1231        drop(source);
1232        assert!(
1233            !terminal.is_terminated(),
1234            "cancelled close detached an active discovery task"
1235        );
1236        tokio::time::timeout(
1237            std::time::Duration::from_secs(1),
1238            terminal.wait_terminated(),
1239        )
1240        .await
1241        .expect("discovery task did not release its generation guard");
1242    }
1243
1244    #[tokio::test]
1245    async fn test_poll_batch_when_not_started() {
1246        let mut source = FileSource::new();
1247        let result = source.poll_batch(100).await;
1248        assert!(result.is_err());
1249    }
1250
1251    #[test]
1252    fn test_checkpoint_roundtrip() {
1253        let mut source = FileSource::new();
1254        source.manifest.insert("test.csv".into());
1255        let cp = source.checkpoint();
1256        assert!(cp.get_offset("manifest").is_some());
1257        assert_eq!(
1258            cp.get_metadata("connector"),
1259            Some(FILE_CHECKPOINT_CONNECTOR)
1260        );
1261        assert_eq!(
1262            cp.get_metadata(CHECKPOINT_VERSION_METADATA),
1263            Some(FILE_CHECKPOINT_VERSION)
1264        );
1265    }
1266
1267    #[tokio::test]
1268    async fn test_resume_from_checkpoint() {
1269        let directory = tempfile::tempdir().unwrap();
1270        let mut source = FileSource::new();
1271
1272        // Build a checkpoint with manifest data.
1273        let mut cp = FileSource::new().checkpoint();
1274        cp.set_offset("manifest", r#"["a.csv"]"#);
1275
1276        let mut config = ConnectorConfig::new("files");
1277        config.set("path", directory.path().to_string_lossy().to_string());
1278        config.set("format", "text");
1279        source
1280            .start(start_request(
1281                config,
1282                SourcePosition::Resume {
1283                    attempt: CheckpointAttempt::new(1, 1),
1284                    checkpoint: cp,
1285                },
1286            ))
1287            .await
1288            .unwrap();
1289        assert_eq!(source.manifest.processed_count(), 1);
1290        assert!(source.manifest.contains("a.csv"));
1291        source.close().await.unwrap();
1292    }
1293
1294    #[tokio::test]
1295    async fn corrupt_resume_manifest_fails_before_discovery_starts() {
1296        let mut cp = FileSource::new().checkpoint();
1297        cp.set_offset("manifest", "{not-json");
1298        let mut config = ConnectorConfig::new("files");
1299        config.set("path", "/tmp");
1300        config.set("format", "text");
1301        let mut source = FileSource::new();
1302        let error = source
1303            .start(start_request(
1304                config,
1305                SourcePosition::Resume {
1306                    attempt: CheckpointAttempt::new(1, 1),
1307                    checkpoint: cp,
1308                },
1309            ))
1310            .await
1311            .expect_err("corrupt durable manifest must fail closed");
1312        assert!(error.to_string().contains("invalid file manifest"));
1313        assert!(source.discovery.is_none());
1314        assert!(!source.is_open);
1315    }
1316
1317    #[tokio::test]
1318    async fn resume_rejects_wrong_checkpoint_identity_or_version() {
1319        let directory = tempfile::tempdir().unwrap();
1320        for (connector, version, expected) in [
1321            (
1322                "generator",
1323                FILE_CHECKPOINT_VERSION,
1324                "belongs to connector 'generator'",
1325            ),
1326            (
1327                FILE_CHECKPOINT_CONNECTOR,
1328                "0",
1329                "requires checkpoint.version=1",
1330            ),
1331        ] {
1332            let mut checkpoint = SourceCheckpoint::new();
1333            checkpoint.set_offset("manifest", "[]");
1334            checkpoint.set_metadata("connector", connector);
1335            checkpoint.set_metadata(CHECKPOINT_VERSION_METADATA, version);
1336
1337            let mut source = FileSource::new();
1338            let error = source
1339                .start(start_request(
1340                    text_source_config(directory.path()),
1341                    SourcePosition::Resume {
1342                        attempt: CheckpointAttempt::canonical(7),
1343                        checkpoint,
1344                    },
1345                ))
1346                .await
1347                .expect_err("non-current file checkpoint must be rejected");
1348            assert!(error.to_string().contains(expected), "{error}");
1349            assert!(source.discovery.is_none());
1350            assert!(!source.is_open);
1351        }
1352    }
1353
1354    #[tokio::test]
1355    async fn resume_rejects_unknown_file_progress_fields() {
1356        let directory = tempfile::tempdir().unwrap();
1357        let mut checkpoint = FileSource::new().checkpoint();
1358        checkpoint.set_offset(
1359            "file_progress",
1360            r#"{"path":"a.txt","size":1,"modified_ms":1,"content_sha256":"00","next_row":1,"legacy_cursor":true}"#,
1361        );
1362        let mut source = FileSource::new();
1363        let error = source
1364            .start(start_request(
1365                text_source_config(directory.path()),
1366                SourcePosition::Resume {
1367                    attempt: CheckpointAttempt::canonical(8),
1368                    checkpoint,
1369                },
1370            ))
1371            .await
1372            .expect_err("same-version unknown progress fields must fail closed");
1373        assert!(error.to_string().contains("unknown field"), "{error}");
1374        assert!(source.discovery.is_none());
1375        assert!(!source.is_open);
1376    }
1377
1378    #[tokio::test]
1379    async fn resume_rejects_remote_progress_before_filesystem_access() {
1380        let directory = tempfile::tempdir().unwrap();
1381        let mut checkpoint = FileSource::new().checkpoint();
1382        checkpoint.set_offset(
1383            "file_progress",
1384            r#"{"path":"az://container/path?sig=do-not-echo","size":1,"modified_ms":1,"content_sha256":"00","next_row":1}"#,
1385        );
1386        let mut source = FileSource::new();
1387        let error = source
1388            .start(start_request(
1389                text_source_config(directory.path()),
1390                SourcePosition::Resume {
1391                    attempt: CheckpointAttempt::canonical(9),
1392                    checkpoint,
1393                },
1394            ))
1395            .await
1396            .expect_err("remote checkpoint progress must fail before filesystem access");
1397        let message = error.to_string();
1398        assert!(message.contains("query parameters"), "{message}");
1399        assert!(!message.contains("do-not-echo"), "{message}");
1400        assert!(source.discovery.is_none());
1401        assert!(!source.is_open);
1402    }
1403
1404    #[tokio::test]
1405    async fn cancelled_poll_retains_unpublished_file_progress() {
1406        let directory = tempfile::tempdir().unwrap();
1407        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1408        let first_path = "staged-first.txt";
1409        let second_path = "staged-second.txt";
1410        let first = b"first\n".to_vec();
1411        let second = b"second\n".to_vec();
1412        let reader = Arc::new(TestFileReader {
1413            files: BTreeMap::from([
1414                (first_path.into(), first.clone()),
1415                (second_path.into(), second.clone()),
1416            ]),
1417            blocked_path: Some(second_path.into()),
1418            read_started: Notify::new(),
1419            release_read: Notify::new(),
1420        });
1421        source.reader = reader.clone();
1422        source.pending_files.push_back(staged(first_path, &first));
1423        source.pending_files.push_back(staged(second_path, &second));
1424
1425        let batch = source.poll_batch(10).await.unwrap().unwrap();
1426        assert_eq!(batch.num_rows(), 1);
1427        assert!(source.manifest.contains(first_path));
1428
1429        {
1430            let poll = source.poll_batch(10);
1431            tokio::pin!(poll);
1432            let read_started = reader.read_started.notified();
1433            tokio::pin!(read_started);
1434            tokio::select! {
1435                biased;
1436                result = &mut poll => panic!("blocked read unexpectedly completed: {result:?}"),
1437                () = &mut read_started => {}
1438            }
1439            // Dropping `poll` here models the runtime cancelling an in-flight
1440            // source poll to service shutdown/control work.
1441        }
1442
1443        assert!(source.manifest.contains(first_path));
1444        assert!(!source.manifest.contains(second_path));
1445        assert_eq!(
1446            source
1447                .pending_files
1448                .front()
1449                .map(|pending| pending.discovered.path.as_str()),
1450            Some(second_path)
1451        );
1452        source.close().await.unwrap();
1453    }
1454
1455    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1456    async fn retired_source_waits_for_inflight_file_read() {
1457        let directory = tempfile::tempdir().unwrap();
1458        let path = "blocked.txt";
1459        let bytes = b"blocked\n".to_vec();
1460        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1461        let (release_tx, release_rx) = std::sync::mpsc::channel();
1462        let reader = Arc::new(BlockingChildFileReader {
1463            bytes: bytes.clone(),
1464            started: std::sync::Mutex::new(Some(started_tx)),
1465            release: std::sync::Mutex::new(Some(release_rx)),
1466        });
1467
1468        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1469        let terminal = source
1470            .terminal_task_tracker()
1471            .expect("file source owns discovery and read tasks");
1472        source.reader = reader;
1473        source.pending_files.push_back(staged(path, &bytes));
1474
1475        let caller = tokio::spawn(async move { source.poll_batch(10).await });
1476        started_rx.await.unwrap();
1477        caller.abort();
1478        let _ = caller.await;
1479        assert!(
1480            !terminal.is_terminated(),
1481            "retirement must retain the started filesystem child"
1482        );
1483
1484        release_tx.send(()).unwrap();
1485        tokio::time::timeout(
1486            std::time::Duration::from_secs(1),
1487            terminal.wait_terminated(),
1488        )
1489        .await
1490        .expect("file source generation did not reach terminal completion");
1491    }
1492
1493    #[tokio::test]
1494    async fn max_records_cursor_resumes_exactly_within_a_file() {
1495        let directory = tempfile::tempdir().unwrap();
1496        let path = "bounded.txt";
1497        let bytes = b"one\ntwo\nthree\n".to_vec();
1498        let reader = Arc::new(TestFileReader {
1499            files: BTreeMap::from([(path.into(), bytes.clone())]),
1500            blocked_path: None,
1501            read_started: Notify::new(),
1502            release_read: Notify::new(),
1503        });
1504
1505        let mut source = started_text_source(directory.path(), SourcePosition::Initial).await;
1506        source.reader = reader.clone();
1507        source.pending_files.push_back(staged(path, &bytes));
1508        let first = source.poll_batch(2).await.unwrap().unwrap();
1509        assert_eq!(first.num_rows(), 2);
1510        assert!(!source.manifest.contains(path));
1511        let checkpoint = source.checkpoint();
1512        let progress: FileProgress = serde_json::from_str(
1513            checkpoint
1514                .get_offset("file_progress")
1515                .expect("partial file cursor must be checkpointed"),
1516        )
1517        .unwrap();
1518        assert_eq!(progress.next_row, 2);
1519        source.close().await.unwrap();
1520
1521        let mut resumed = started_text_source(
1522            directory.path(),
1523            SourcePosition::Resume {
1524                attempt: CheckpointAttempt::canonical(11),
1525                checkpoint,
1526            },
1527        )
1528        .await;
1529        resumed.reader = reader;
1530        let remaining = resumed.poll_batch(2).await.unwrap().unwrap();
1531        assert_eq!(remaining.num_rows(), 1);
1532        assert!(resumed.manifest.contains(path));
1533        assert!(resumed.checkpoint().get_offset("file_progress").is_none());
1534        resumed.close().await.unwrap();
1535    }
1536
1537    #[test]
1538    fn test_source_contract() {
1539        let source = FileSource::new();
1540        let contract = source
1541            .contract(&ConnectorConfig::new("files"))
1542            .expect("static file contract");
1543        assert_eq!(contract.consistency, SourceConsistency::Replayable);
1544        assert_eq!(contract.topology, SourceTopology::Singleton);
1545    }
1546}