Skip to main content

faucet_source_file/
stream.rs

1//! File source executor — the one module that performs I/O.
2
3use crate::config::{FileSourceConfig, IncrementalBy};
4use crate::http::HttpFetcher;
5use crate::plan::{self, Bookmark, Candidate};
6use async_trait::async_trait;
7use faucet_common_file::{resolution_name, url_file_name};
8use faucet_core::compression::Compression;
9use faucet_core::observability::RecorderSlot;
10use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
11use faucet_core::{FaucetError, FileFormat, FileInput, FormatOptions, Stream, StreamPage};
12use futures::stream::StreamExt;
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::pin::Pin;
16use std::sync::{Arc, Mutex};
17
18type Reader = Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>;
19
20/// One file, opened in the shape its format decodes from. Prefetched with an
21/// ordered look-ahead of `concurrency`: a JSON Lines file holds only an open
22/// reader, a local uncompressed binary container only an open handle, and
23/// every other shape the file's bytes.
24enum Opened {
25    Lines(Reader),
26    #[cfg(feature = "file-format-csv")]
27    Csv(Reader),
28    Bytes(Vec<u8>),
29    Local(std::fs::File),
30    Skip,
31}
32
33/// Per-run decode state shared with the blocking decoders: the Avro / ORC
34/// decoders anchored on the first file of their format, and the first Parquet
35/// file's schema.
36pub(crate) struct Decoders {
37    opts: FormatOptions,
38    max_decoded_bytes: u64,
39    containers: HashMap<&'static str, faucet_core::ContainerDecoder>,
40    #[cfg(feature = "file-format-parquet")]
41    parquet: Option<(String, arrow::datatypes::SchemaRef)>,
42    #[cfg_attr(not(feature = "file-format-parquet"), allow(dead_code))]
43    parquet_columns: Option<Vec<String>>,
44}
45
46impl Decoders {
47    fn new(
48        opts: FormatOptions,
49        max_decoded_bytes: u64,
50        parquet_columns: Option<Vec<String>>,
51    ) -> Arc<Mutex<Self>> {
52        Arc::new(Mutex::new(Self {
53            opts,
54            max_decoded_bytes,
55            parquet_columns,
56            containers: HashMap::new(),
57            #[cfg(feature = "file-format-parquet")]
58            parquet: None,
59        }))
60    }
61
62    fn container(
63        &mut self,
64        format: FileFormat,
65    ) -> Result<&mut faucet_core::ContainerDecoder, FaucetError> {
66        let key = format.as_str();
67        if !self.containers.contains_key(key) {
68            let d = faucet_core::ContainerDecoder::new(format, &self.opts)?
69                .with_max_decoded_bytes(self.max_decoded_bytes);
70            self.containers.insert(key, d);
71        }
72        Ok(self.containers.get_mut(key).expect("inserted above"))
73    }
74}
75
76/// A local-filesystem (or single `http(s)://` file) source.
77pub struct FileSource {
78    config: FileSourceConfig,
79    http: Option<HttpFetcher>,
80    start: Mutex<Option<Bookmark>>,
81    shard: Mutex<Option<HashShard>>,
82    roundtrips: RecorderSlot,
83    /// Backend-call context (#880) for `http(s)://` paths.
84    calls: faucet_core::CallSlot,
85    #[cfg(feature = "encryption")]
86    encryption: Option<faucet_core::CompiledEncryption>,
87}
88
89impl FileSource {
90    /// Validate the config and build the HTTP client when `path` is a URL.
91    /// Performs no filesystem I/O.
92    pub fn new(config: FileSourceConfig) -> Result<Self, FaucetError> {
93        config.validate()?;
94        let http = if config.is_http() {
95            Some(HttpFetcher::with_timeouts(
96                &config.headers,
97                config.http_retries,
98                std::time::Duration::from_secs(config.http_connect_timeout_secs),
99                std::time::Duration::from_secs(config.http_read_timeout_secs),
100            )?)
101        } else {
102            None
103        };
104        Ok(Self {
105            http,
106            start: Mutex::new(None),
107            shard: Mutex::new(None),
108            roundtrips: RecorderSlot::new(),
109            calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
110            #[cfg(feature = "encryption")]
111            encryption: config
112                .encryption
113                .as_ref()
114                .map(faucet_core::CompiledEncryption::compile)
115                .transpose()?,
116            config,
117        })
118    }
119
120    fn by(&self) -> Option<IncrementalBy> {
121        self.config.incremental.map(|i| i.by)
122    }
123
124    /// The format of `path`, or `None` when auto-detection finds none and
125    /// `strict` is off (the file is skipped with a warning).
126    fn format_of(&self, path: &str) -> Result<Option<FileFormat>, FaucetError> {
127        let found = self
128            .config
129            .format
130            .resolve(resolution_name(path), self.config.strict)
131            .map_err(|e| FaucetError::Source(format!("file source: {e}")))?;
132        if found.is_none() {
133            tracing::warn!(path = %path, "file source: skipping a file with no recognised extension");
134        }
135        Ok(found)
136    }
137
138    fn codec_of(&self, path: &str) -> Compression {
139        let codec = faucet_common_file::resolve_compression(self.config.compression, path);
140        faucet_core::compression::warn_mismatch(resolution_name(path), codec);
141        codec
142    }
143
144    /// The files this run reads, in order.
145    async fn candidates(&self, apply_bookmark: bool) -> Result<Vec<Candidate>, FaucetError> {
146        self.candidates_at(&self.config.path, apply_bookmark).await
147    }
148
149    async fn candidates_at(
150        &self,
151        root: &str,
152        apply_bookmark: bool,
153    ) -> Result<Vec<Candidate>, FaucetError> {
154        let mut files = match &self.http {
155            Some(http) => {
156                let needs_mtime = matches!(self.by(), Some(IncrementalBy::Mtime))
157                    || self.config.stable_for_secs.is_some();
158                let mtime_ns = if needs_mtime {
159                    let meta = http
160                        .head_in(root, &self.roundtrips, &self.calls.get())
161                        .await?;
162                    Some(meta.last_modified_ns.ok_or_else(|| {
163                        FaucetError::Source(format!(
164                            "file source: {} sends no Last-Modified header, which `incremental: \
165                             {{by: mtime}}` / `stable_for_secs` need",
166                            root
167                        ))
168                    })?)
169                } else {
170                    None
171                };
172                vec![Candidate {
173                    path: root.to_string(),
174                    mtime_ns,
175                    ctime_ns: None,
176                }]
177            }
178            None => {
179                let path = root.to_string();
180                let recursive = self.config.recursive;
181                tokio::task::spawn_blocking(move || {
182                    let files = plan::list_local(&path, recursive)?;
183                    for f in &files {
184                        faucet_core::path_guard::check(std::path::Path::new(&f.path))?;
185                    }
186                    Ok::<_, FaucetError>(files)
187                })
188                .await
189                .map_err(|e| {
190                    FaucetError::Source(format!("file source: listing task failed: {e}"))
191                })??
192            }
193        };
194        if let Some(member) = *self.shard.lock().expect("shard mutex") {
195            files.retain(|f| member.contains(&f.path));
196        }
197        let bookmark = if apply_bookmark {
198            self.start.lock().expect("bookmark mutex").clone()
199        } else {
200            None
201        };
202        let mut files = plan::select(
203            files,
204            if apply_bookmark { self.by() } else { None },
205            bookmark.as_ref(),
206            self.config.stable_for_secs,
207            now_ns(),
208        )?;
209        if let Some(max) = self.config.max_files {
210            files.truncate(max);
211        }
212        Ok(files)
213    }
214
215    async fn open_reader(&self, path: &str) -> Result<Reader, FaucetError> {
216        let raw = self.open_raw(path).await?;
217        Ok(faucet_core::compression::wrap_async_reader(
218            raw,
219            self.codec_of(path),
220        ))
221    }
222
223    async fn open_raw(&self, path: &str) -> Result<Reader, FaucetError> {
224        Ok(match &self.http {
225            Some(http) => {
226                let resp = http
227                    .get_in(path, &self.roundtrips, &self.calls.get())
228                    .await?;
229                let body = resp
230                    .bytes_stream()
231                    .map(|r| r.map_err(std::io::Error::other));
232                Box::pin(tokio_util::io::StreamReader::new(body))
233            }
234            None => Box::pin(tokio::io::BufReader::new(tokio::fs::File::from_std(
235                open_local(path)?,
236            ))),
237        })
238    }
239
240    async fn open(&self, path: &str) -> Result<Opened, FaucetError> {
241        let Some(format) = self.format_of(path)? else {
242            return Ok(Opened::Skip);
243        };
244        #[cfg(feature = "encryption")]
245        if let Some(enc) = &self.encryption {
246            let plain = self.read_decrypted(path, format, enc).await?;
247            return Ok(match format {
248                FileFormat::JsonLines => Opened::Lines(Box::pin(std::io::Cursor::new(plain))),
249                #[cfg(feature = "file-format-csv")]
250                FileFormat::Csv => Opened::Csv(Box::pin(std::io::Cursor::new(plain))),
251                _ => Opened::Bytes(plain),
252            });
253        }
254        if format == FileFormat::JsonLines {
255            return Ok(Opened::Lines(self.open_reader(path).await?));
256        }
257        #[cfg(feature = "file-format-csv")]
258        if format == FileFormat::Csv {
259            return Ok(Opened::Csv(self.open_reader(path).await?));
260        }
261        let binary = format.is_container() || format == FileFormat::Parquet;
262        if binary && self.http.is_none() && self.codec_of(path) == Compression::None {
263            return Ok(Opened::Local(open_local(path)?));
264        }
265        let reader = self.open_reader(path).await?;
266        let bytes = faucet_core::file_format::read_to_end_capped(
267            reader,
268            self.config.max_object_bytes,
269            path,
270        )
271        .await?;
272        Ok(Opened::Bytes(bytes))
273    }
274
275    #[cfg(feature = "encryption")]
276    async fn read_decrypted(
277        &self,
278        path: &str,
279        format: FileFormat,
280        enc: &faucet_core::CompiledEncryption,
281    ) -> Result<Vec<u8>, FaucetError> {
282        let max = self.config.max_object_bytes;
283        let raw =
284            faucet_core::file_format::read_to_end_capped(self.open_raw(path).await?, max, path)
285                .await?;
286        let codec = self.codec_of(path);
287        let fail = |e: FaucetError| FaucetError::Source(format!("file source: '{path}': {e}"));
288        if faucet_core::encryption::is_encrypted(&raw) {
289            let sealed = enc.decrypt(&raw).map_err(fail)?;
290            let mut plain = Vec::new();
291            std::io::Read::read_to_end(
292                &mut std::io::Read::take(
293                    faucet_core::compression::wrap_sync_reader(std::io::Cursor::new(sealed), codec),
294                    max.saturating_add(1),
295                ),
296                &mut plain,
297            )
298            .map_err(|e| read_err(path, e))?;
299            faucet_core::file_format::check_object_size(plain.len() as u64, max, path)?;
300            return Ok(plain);
301        }
302        if matches!(format, FileFormat::JsonLines | FileFormat::RawText)
303            && codec == Compression::None
304        {
305            return crate::decrypt::lines(&raw, enc).map_err(fail);
306        }
307        Err(FaucetError::Source(format!(
308            "file source: '{path}' is not encrypted, but `encryption` is set — refusing to read \
309             plaintext as if it were authenticated"
310        )))
311    }
312
313    fn root(&self, context: &HashMap<String, Value>) -> String {
314        if context.is_empty() {
315            self.config.path.clone()
316        } else {
317            faucet_core::util::substitute_context(&self.config.path, context)
318        }
319    }
320
321    async fn check_parquet_schemas(&self, files: &[Candidate]) -> Result<(), FaucetError> {
322        #[cfg(feature = "file-format-parquet")]
323        {
324            if self.http.is_some() || self.config.encryption_set() {
325                return Ok(());
326            }
327            let mut local = Vec::new();
328            for f in files {
329                if self.format_of(&f.path)? == Some(FileFormat::Parquet)
330                    && self.codec_of(&f.path) == Compression::None
331                {
332                    local.push((f.path.clone(), open_local(&f.path)?));
333                }
334            }
335            if local.len() < 2 {
336                return Ok(());
337            }
338            let columns = self.config.parquet.columns.clone();
339            tokio::task::spawn_blocking(move || {
340                crate::parquet::check_schemas(local, columns.as_deref())
341            })
342            .await
343            .map_err(|e| FaucetError::Source(format!("file source: schema check failed: {e}")))??;
344        }
345        #[cfg(not(feature = "file-format-parquet"))]
346        let _ = files;
347        Ok(())
348    }
349
350    fn prefetch<'a>(
351        &'a self,
352        files: &'a [Candidate],
353    ) -> impl futures::Stream<Item = (&'a Candidate, Result<Opened, FaucetError>)> + 'a {
354        futures::stream::iter(files.iter())
355            .map(move |c| async move { (c, self.open(&c.path).await) })
356            .buffered(self.config.concurrency.max(1))
357    }
358}
359
360fn now_ns() -> i64 {
361    chrono::Utc::now().timestamp_nanos_opt().unwrap_or(i64::MAX)
362}
363
364/// Open a listed local file. Under a scoped reference policy with path roots
365/// the file is reached without following links and must resolve under a root.
366fn open_local(path: &str) -> Result<std::fs::File, FaucetError> {
367    match faucet_core::http::current_reference_policy() {
368        Some(policy) if policy.path_roots().is_some() => {
369            faucet_core::path_guard::open_within(std::path::Path::new(path), Some(&policy))
370        }
371        _ => std::fs::File::open(path).map_err(|e| read_err(path, e)),
372    }
373}
374
375fn read_err(path: &str, e: impl std::fmt::Display) -> FaucetError {
376    FaucetError::Source(format!("file source: read '{path}': {e}"))
377}
378
379/// Decode the bytes of a whole-object format into records.
380async fn decode_whole(
381    path: &str,
382    format: FileFormat,
383    bytes: Vec<u8>,
384    opts: &FormatOptions,
385    max_decoded_bytes: u64,
386) -> Result<Vec<Value>, FaucetError> {
387    if format == FileFormat::RawText {
388        let content = String::from_utf8(bytes)
389            .map_err(|e| FaucetError::Source(format!("file source: '{path}' is not UTF-8: {e}")))?;
390        return Ok(vec![json!({"path": path, "content": content})]);
391    }
392    faucet_core::file_format::decode_owned_bounded(bytes, format, opts, max_decoded_bytes)
393        .await
394        .map_err(|e| FaucetError::Source(format!("file source: '{path}': {e}")))
395}
396
397/// Run a binary decoder (Avro / ORC / Parquet) on a blocking thread, handing
398/// back chunks of `T` through a small channel so memory stays at a couple of
399/// chunks however large the file is.
400fn spawn_blocking_decode<T: Send + 'static>(
401    work: impl FnOnce(&mut dyn FnMut(T) -> Result<(), FaucetError>) -> Result<(), FaucetError>
402    + Send
403    + 'static,
404) -> (
405    tokio::sync::mpsc::Receiver<T>,
406    tokio::task::JoinHandle<Result<(), FaucetError>>,
407) {
408    let (tx, rx) = tokio::sync::mpsc::channel(2);
409    let handle = tokio::task::spawn_blocking(move || {
410        work(&mut |item| {
411            tx.blocking_send(item)
412                .map_err(|_| FaucetError::Source("file source: decode consumer went away".into()))
413        })
414    });
415    (rx, handle)
416}
417
418async fn join(handle: tokio::task::JoinHandle<Result<(), FaucetError>>) -> Result<(), FaucetError> {
419    handle
420        .await
421        .map_err(|e| FaucetError::Source(format!("file source: decode task failed: {e}")))?
422}
423
424fn input_of(opened: Opened) -> FileInput {
425    match opened {
426        Opened::Local(f) => FileInput::File(f),
427        Opened::Bytes(b) => FileInput::Bytes(b),
428        _ => unreachable!("only binary formats are decoded here"),
429    }
430}
431
432/// Decode one binary file's records on a blocking thread.
433fn records_task(
434    decoders: Arc<Mutex<Decoders>>,
435    path: String,
436    format: FileFormat,
437    input: FileInput,
438    chunk: usize,
439) -> (
440    tokio::sync::mpsc::Receiver<Vec<Value>>,
441    tokio::task::JoinHandle<Result<(), FaucetError>>,
442) {
443    spawn_blocking_decode(move |send| {
444        let mut d = decoders.lock().expect("decoder mutex");
445        match format {
446            #[cfg(feature = "file-format-parquet")]
447            FileFormat::Parquet => crate::parquet::read(&mut d, &path, input, chunk, &mut |b| {
448                send(faucet_core::columnar::record_batch_to_values(&b)?)
449            }),
450            #[cfg(not(feature = "file-format-parquet"))]
451            FileFormat::Parquet => Err(crate::parquet_missing()),
452            _ => d.container(format)?.records(&path, input, chunk, send),
453        }
454    })
455}
456
457/// Decode one binary file's Arrow batches on a blocking thread.
458#[cfg(feature = "arrow")]
459fn batches_task(
460    decoders: Arc<Mutex<Decoders>>,
461    path: String,
462    format: FileFormat,
463    input: FileInput,
464    batch_size: usize,
465) -> (
466    tokio::sync::mpsc::Receiver<arrow::array::RecordBatch>,
467    tokio::task::JoinHandle<Result<(), FaucetError>>,
468) {
469    spawn_blocking_decode(move |send| {
470        let mut d = decoders.lock().expect("decoder mutex");
471        match format {
472            #[cfg(feature = "file-format-parquet")]
473            FileFormat::Parquet => crate::parquet::read(&mut d, &path, input, batch_size, send),
474            #[cfg(not(feature = "file-format-parquet"))]
475            FileFormat::Parquet => Err(crate::parquet_missing()),
476            _ => d
477                .container(format)?
478                .batches(&path, input, batch_size, send)
479                .map(|_| ()),
480        }
481    })
482}
483
484#[cfg(feature = "file-format-parquet")]
485impl Decoders {
486    pub(crate) fn parquet_reference(
487        &mut self,
488    ) -> &mut Option<(String, arrow::datatypes::SchemaRef)> {
489        &mut self.parquet
490    }
491
492    pub(crate) fn parquet_columns(&self) -> Option<&[String]> {
493        self.parquet_columns.as_deref()
494    }
495}
496
497#[async_trait]
498impl faucet_core::Source for FileSource {
499    async fn fetch_with_context(
500        &self,
501        context: &HashMap<String, Value>,
502    ) -> Result<Vec<Value>, FaucetError> {
503        let mut out = Vec::new();
504        let mut pages = self.stream_pages(context, self.config.batch_size);
505        while let Some(page) = pages.next().await {
506            out.extend(page?.records);
507        }
508        Ok(out)
509    }
510
511    /// Stream every selected file in order. Pages carry up to `batch_size`
512    /// records; in incremental mode a page never spans two files and the last
513    /// page of each file carries the bookmark past it, so an interrupted run
514    /// resumes at the next unread file.
515    fn stream_pages<'a>(
516        &'a self,
517        context: &'a HashMap<String, Value>,
518        _batch_size: usize,
519    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
520        let batch_size = self.config.batch_size;
521        Box::pin(async_stream::try_stream! {
522            let root = self.root(context);
523            let files = self.candidates_at(&root, true).await?;
524            tracing::info!(path = %root, files = files.len(), "file source listed files");
525            self.check_parquet_schemas(&files).await?;
526            let by = self.by();
527            let mut bookmark = self.start.lock().expect("bookmark mutex").clone();
528            let decoders = Decoders::new(self.config.format_options(), self.config.decoded_bytes_limit(), self.config.parquet.columns.clone());
529            let opts = self.config.format_options();
530            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
531            let mut buffer: Vec<Value> = Vec::new();
532            let mut opened_files = self.prefetch(&files);
533
534            while let Some((file, opened)) = opened_files.next().await {
535                let path = file.path.as_str();
536                let opened = opened?;
537                let format = match &opened {
538                    Opened::Skip => None,
539                    _ => self.format_of(path)?,
540                };
541                if let Some(format) = format {
542                    match opened {
543                        Opened::Lines(reader) => {
544                            // One line is bounded by `max_object_bytes`, so a
545                            // body with no newline fails instead of being
546                            // buffered whole.
547                            let mut lines = faucet_core::file_format::capped_lines(reader, self.config.max_object_bytes);
548                            let mut n = 0usize;
549                            while let Some(line) = lines.next_line().await.map_err(|e| read_err(path, e))? {
550                                n += 1;
551                                let line = line.trim();
552                                if line.is_empty() {
553                                    continue;
554                                }
555                                buffer.push(faucet_core::fidelity::from_str(line).map_err(|e| {
556                                    FaucetError::Source(format!("file source: '{path}' line {n}: {e}"))
557                                })?);
558                                if buffer.len() >= chunk {
559                                    yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
560                                }
561                            }
562                        }
563                        #[cfg(feature = "file-format-csv")]
564                        Opened::Csv(reader) => {
565                            let reader = faucet_core::file_format::line_capped(reader, self.config.max_object_bytes);
566                            let mut rows = faucet_core::file_format::csv::CsvRowReader::new(reader, &opts.csv, false)?;
567                            while let Some(r) = rows.next_record().await.map_err(|e| {
568                                FaucetError::Source(format!("file source: '{path}': {e}"))
569                            })? {
570                                buffer.push(r);
571                                if buffer.len() >= chunk {
572                                    yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
573                                }
574                            }
575                        }
576                        other if format.is_container() || format == FileFormat::Parquet => {
577                            let (mut rx, handle) = records_task(decoders.clone(), path.to_string(), format, input_of(other), chunk);
578                            while let Some(rows) = rx.recv().await {
579                                for r in rows {
580                                    buffer.push(r);
581                                    if buffer.len() >= chunk {
582                                        yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
583                                    }
584                                }
585                            }
586                            join(handle).await?;
587                        }
588                        Opened::Bytes(bytes) => {
589                            for r in decode_whole(path, format, bytes, &opts, self.config.decoded_bytes_limit()).await? {
590                                buffer.push(r);
591                                if buffer.len() >= chunk {
592                                    yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
593                                }
594                            }
595                        }
596                        Opened::Local(_) | Opened::Skip => unreachable!("binary and skipped files handled above"),
597                    }
598                    if batch_size == 0 && !buffer.is_empty() && by.is_none() {
599                        yield StreamPage { records: std::mem::take(&mut buffer), bookmark: None };
600                    }
601                }
602                if let Some(by) = by {
603                    bookmark = Some(plan::advance(bookmark, by, file)?);
604                    let mark = bookmark.as_ref().map(Bookmark::to_value);
605                    yield StreamPage { records: std::mem::take(&mut buffer), bookmark: mark };
606                }
607            }
608            if !buffer.is_empty() {
609                yield StreamPage { records: buffer, bookmark: None };
610            }
611        })
612    }
613
614    /// The columnar path is taken for an explicit `avro`, `orc` or `parquet`
615    /// format; `auto` cannot promise every file decodes to Arrow.
616    #[cfg(feature = "arrow")]
617    fn supports_columnar(&self) -> bool {
618        self.config
619            .format
620            .explicit()
621            .is_some_and(|f| f.is_container() || f == FileFormat::Parquet)
622    }
623
624    #[cfg(feature = "arrow")]
625    fn stream_batches<'a>(
626        &'a self,
627        context: &'a HashMap<String, Value>,
628        _batch_size: usize,
629    ) -> Pin<
630        Box<
631            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
632        >,
633    > {
634        Box::pin(async_stream::try_stream! {
635            if !self.supports_columnar() {
636                Err(FaucetError::Source(
637                    "file source: stream_batches needs format avro, orc or parquet".into(),
638                ))?;
639            }
640            let format = self.config.format.explicit().expect("checked by supports_columnar");
641            let root = self.root(context);
642            let files = self.candidates_at(&root, true).await?;
643            self.check_parquet_schemas(&files).await?;
644            let by = self.by();
645            let mut bookmark = self.start.lock().expect("bookmark mutex").clone();
646            let decoders = Decoders::new(self.config.format_options(), self.config.decoded_bytes_limit(), self.config.parquet.columns.clone());
647            let mut opened_files = self.prefetch(&files);
648            while let Some((file, opened)) = opened_files.next().await {
649                let (mut rx, handle) = batches_task(decoders.clone(), file.path.clone(), format, input_of(opened?), self.config.batch_size);
650                let mut last: Option<arrow::array::RecordBatch> = None;
651                while let Some(batch) = rx.recv().await {
652                    if let Some(prev) = last.replace(batch) {
653                        yield faucet_core::columnar::ColumnarPage::new(prev, None);
654                    }
655                }
656                join(handle).await?;
657                let mark = match by {
658                    Some(by) => {
659                        bookmark = Some(plan::advance(bookmark, by, file)?);
660                        bookmark.as_ref().map(Bookmark::to_value)
661                    }
662                    None => None,
663                };
664                match last {
665                    Some(batch) => yield faucet_core::columnar::ColumnarPage::new(batch, mark),
666                    None if mark.is_some() => {
667                        let schema = std::sync::Arc::new(arrow::datatypes::Schema::empty());
668                        yield faucet_core::columnar::ColumnarPage::new(arrow::array::RecordBatch::new_empty(schema), mark);
669                    }
670                    None => {}
671                }
672            }
673        })
674    }
675
676    fn config_schema(&self) -> Value {
677        serde_json::to_value(faucet_core::schema_for!(FileSourceConfig))
678            .expect("schema serialization")
679    }
680
681    fn connector_name(&self) -> &'static str {
682        "file"
683    }
684
685    fn set_roundtrip_recorder(&self, recorder: Arc<faucet_core::observability::RoundtripRecorder>) {
686        self.roundtrips.install(recorder);
687    }
688
689    fn set_call_context(&self, ctx: faucet_core::CallContext) {
690        self.calls.install(ctx);
691    }
692
693    fn dataset_uri(&self) -> String {
694        if self.http.is_some() {
695            return faucet_core::util::redact_uri_credentials(&self.config.path);
696        }
697        let p = std::path::Path::new(&self.config.path);
698        let abs = if p.is_absolute() {
699            p.to_path_buf()
700        } else {
701            std::env::current_dir()
702                .map(|d| d.join(p))
703                .unwrap_or_else(|_| p.to_path_buf())
704        };
705        format!("file://{}", abs.display())
706    }
707
708    /// The bookmark lives under a key derived from `path`, so two sources
709    /// reading different paths never share a position.
710    fn state_key(&self) -> Option<String> {
711        self.config.incremental.map(|_| {
712            format!(
713                "file:{:016x}",
714                faucet_core::shard::shard_hash(&self.config.path)
715            )
716        })
717    }
718
719    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
720        let Some(by) = self.by() else {
721            return Ok(());
722        };
723        if bookmark.is_null() {
724            return Ok(());
725        }
726        *self.start.lock().expect("bookmark mutex") = Some(Bookmark::from_value(&bookmark, by)?);
727        Ok(())
728    }
729
730    fn is_shardable(&self) -> bool {
731        self.http.is_none()
732    }
733
734    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
735        Ok(if self.http.is_some() {
736            plan_hash_shards(1)
737        } else {
738            plan_hash_shards(target)
739        })
740    }
741
742    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
743        *self.shard.lock().expect("shard mutex") = parse_hash_shard(shard, "file")?;
744        Ok(())
745    }
746
747    fn supports_discover(&self) -> bool {
748        true
749    }
750
751    /// One dataset per readable file under `path` — no file is opened.
752    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
753        let files = self.candidates(false).await?;
754        let mut out = Vec::new();
755        for f in files {
756            let Some(format) = self.format_of(&f.path)? else {
757                continue;
758            };
759            let name = if self.http.is_some() {
760                url_file_name(&f.path).to_string()
761            } else {
762                std::path::Path::new(&f.path)
763                    .strip_prefix(&self.config.path)
764                    .ok()
765                    .map(|p| p.to_string_lossy().into_owned())
766                    .filter(|s| !s.is_empty())
767                    .unwrap_or_else(|| f.path.clone())
768            };
769            out.push(faucet_core::DatasetDescriptor::new(
770                name,
771                "file",
772                json!({"path": f.path, "format": format.as_str()}),
773            ));
774        }
775        Ok(out)
776    }
777
778    /// Reachability without reading data: `HEAD` for a URL, a listing for a
779    /// local path.
780    async fn check(
781        &self,
782        ctx: &faucet_core::check::CheckContext,
783    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
784        use faucet_core::check::{CheckReport, Probe};
785        let started = std::time::Instant::now();
786        if let Some(http) = &self.http {
787            let probe = match tokio::time::timeout(
788                ctx.timeout,
789                http.head_in(&self.config.path, &self.roundtrips, &self.calls.get()),
790            )
791            .await
792            {
793                Err(_) => Probe::fail("head", started.elapsed(), "timed out"),
794                Ok(Ok(_)) => Probe::pass("head", started.elapsed()),
795                Ok(Err(e)) => Probe::fail("head", started.elapsed(), e.to_string()),
796            };
797            return Ok(CheckReport::single(probe));
798        }
799        let probe = match tokio::time::timeout(ctx.timeout, self.candidates(false)).await {
800            Err(_) => Probe::fail("list", started.elapsed(), "timed out"),
801            Ok(Ok(files)) if files.is_empty() => Probe::fail_hint(
802                "list",
803                started.elapsed(),
804                format!("no files match {}", self.config.path),
805                "check `path` (and `recursive` for nested directories)",
806            ),
807            Ok(Ok(_)) => Probe::pass("list", started.elapsed()),
808            Ok(Err(e)) => Probe::fail("list", started.elapsed(), e.to_string()),
809        };
810        Ok(CheckReport::single(probe))
811    }
812}