1use std::collections::VecDeque;
7use std::sync::Arc;
8
9use arrow_array::RecordBatch;
10use arrow_schema::{DataType, Field, Schema, SchemaRef};
11use async_trait::async_trait;
12use tracing::{debug, info};
13
14use crate::checkpoint::SourceCheckpoint;
15use crate::config::ConnectorConfig;
16use crate::connector::{
17 ConnectorTaskGuard, ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceConnector,
18 SourceConsistency, SourceContract, SourceInputMode, SourcePosition, SourceStart,
19 SourceTopology,
20};
21use crate::error::ConnectorError;
22use crate::schema::traits::FormatDecoder;
23use crate::schema::types::RawRecord;
24
25use super::config::{local_files_path, FileFormat, FileSourceConfig};
26use super::discovery::{DiscoveredFile, DiscoveryConfig, FileDiscoveryEngine};
27use super::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
104pub struct FileSource {
109 config: Option<FileSourceConfig>,
111 schema: SchemaRef,
113 schema_contract: Option<String>,
114 decoder: Option<Arc<dyn FormatDecoder>>,
116 #[cfg_attr(not(feature = "fpml"), allow(dead_code))]
118 registry: Option<prometheus::Registry>,
119 discovery: Option<FileDiscoveryEngine>,
121 manifest: FileIngestionManifest,
123 pending_files: VecDeque<PendingFile>,
126 current_file: Option<DecodedFile>,
128 reader: Arc<dyn FileReader>,
130 is_open: bool,
132 restart_forbidden: bool,
134 task_owner: ConnectorTaskOwner,
136 task_tracker: ConnectorTaskTracker,
138}
139
140impl FileSource {
141 #[must_use]
143 pub fn new() -> Self {
144 Self::with_registry(None)
145 }
146
147 #[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 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 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 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 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 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 }
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 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 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
648fn 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 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 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#[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 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 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 }
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}