Skip to main content

faucet_source_parquet/
stream.rs

1//! Parquet source stream executor.
2//!
3//! Reads one or more Parquet files (local file, local glob, or S3 object /
4//! prefix) and yields each row as a `serde_json::Value::Object`. RecordBatches
5//! are streamed and converted incrementally — no whole-file buffering.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
14use faucet_core::{FaucetError, Stream, StreamPage};
15use futures::{StreamExt, TryStreamExt, stream};
16use object_store::ObjectStore;
17use object_store::aws::AmazonS3Builder;
18use object_store::path::Path as ObjectPath;
19use parquet::arrow::ProjectionMask;
20use parquet::arrow::async_reader::{ParquetObjectReader, ParquetRecordBatchStreamBuilder};
21use serde_json::Value;
22
23use crate::config::{ParquetLocation, ParquetS3Config, ParquetSourceConfig};
24use crate::convert::record_batch_to_json;
25
26/// A source that reads Parquet files into JSON records.
27pub struct ParquetSource {
28    config: ParquetSourceConfig,
29    /// Eagerly-constructed object store used for S3 sources. `None` for
30    /// local file / glob sources.
31    s3_store: Option<Arc<dyn ObjectStore>>,
32    /// Shard applied by the cluster coordinator (Mode B). `None` (or a
33    /// degenerate single-shard set) reads every resolved file. Stored behind a
34    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
35    applied_shard: Mutex<Option<HashShard>>,
36}
37
38impl ParquetSource {
39    /// Build a new Parquet source from `config`.
40    ///
41    /// Performs eager validation (concurrency > 0, mutually exclusive S3
42    /// `key`/`prefix`) and pre-builds the S3 client when applicable so it can
43    /// be reused across concurrent file reads.
44    pub async fn new(config: ParquetSourceConfig) -> Result<Self, FaucetError> {
45        // `batch_size == 0` is the "no batching" sentinel — accepted, and
46        // means "let the file's native row-group size drive page cadence".
47        // See `ParquetSourceConfig::batch_size` for the full contract.
48        if config.concurrency == 0 {
49            return Err(FaucetError::Config(
50                "parquet source: concurrency must be > 0".into(),
51            ));
52        }
53
54        let s3_store = match &config.source {
55            ParquetLocation::S3(s3) => Some(build_s3_store(s3)?),
56            _ => None,
57        };
58
59        Ok(Self {
60            config,
61            s3_store,
62            applied_shard: Mutex::new(None),
63        })
64    }
65
66    /// Resolve the configured `source` into the full (unsharded) list of files.
67    ///
68    /// For S3 prefix mode this issues a list-objects call. For glob mode this
69    /// expands the pattern. The result is sorted for deterministic ordering.
70    async fn resolve_all_files(
71        &self,
72        context: &HashMap<String, Value>,
73    ) -> Result<Vec<FileTarget>, FaucetError> {
74        match &self.config.source {
75            ParquetLocation::LocalPath { path } => {
76                let resolved = substitute(path, context);
77                Ok(vec![FileTarget::Local(PathBuf::from(resolved))])
78            }
79            ParquetLocation::Glob { pattern } => {
80                let resolved = substitute(pattern, context);
81                expand_glob(&resolved)
82            }
83            ParquetLocation::S3(s3) => self.resolve_s3_files(s3, context).await,
84        }
85    }
86
87    /// Resolve the configured `source` into the concrete list of files to read,
88    /// narrowed to the applied shard (if any).
89    async fn resolve_files(
90        &self,
91        context: &HashMap<String, Value>,
92    ) -> Result<Vec<FileTarget>, FaucetError> {
93        Ok(self.shard_filter(self.resolve_all_files(context).await?))
94    }
95
96    /// Retain only the files belonging to the applied shard (hash of the
97    /// file's display path modulo `shards`). A no-op when no shard is applied.
98    /// Every worker resolves the same sorted file list and hashes the same
99    /// deterministic path strings, so the partition is disjoint and complete.
100    fn shard_filter(&self, targets: Vec<FileTarget>) -> Vec<FileTarget> {
101        match *self.applied_shard.lock().expect("shard mutex poisoned") {
102            Some(member) => targets
103                .into_iter()
104                .filter(|t| member.contains(&t.display()))
105                .collect(),
106            None => targets,
107        }
108    }
109
110    async fn resolve_s3_files(
111        &self,
112        s3: &ParquetS3Config,
113        context: &HashMap<String, Value>,
114    ) -> Result<Vec<FileTarget>, FaucetError> {
115        match (&s3.key, &s3.prefix) {
116            (Some(_), Some(_)) => Err(FaucetError::Config(
117                "parquet source: S3 config cannot set both `key` and `prefix`".into(),
118            )),
119            (None, None) => Err(FaucetError::Config(
120                "parquet source: S3 config requires one of `key` or `prefix`".into(),
121            )),
122            (Some(key), None) => {
123                let key = substitute(key, context);
124                Ok(vec![FileTarget::S3(ObjectPath::from(key))])
125            }
126            (None, Some(prefix)) => {
127                let prefix = substitute(prefix, context);
128                let store = self.s3_store.as_ref().ok_or_else(|| {
129                    FaucetError::Source("parquet source: S3 store not initialised".into())
130                })?;
131                list_s3_prefix(store.as_ref(), &prefix).await
132            }
133        }
134    }
135
136    /// Read a single resolved file, returning the rows it yields plus the
137    /// Arrow schema used to decode it (so the caller can detect divergence
138    /// across multiple files).
139    async fn read_file(&self, target: &FileTarget) -> Result<FileOutput, FaucetError> {
140        let display = target.display();
141        match target {
142            FileTarget::Local(path) => {
143                let file = tokio::fs::File::open(path).await.map_err(|e| {
144                    FaucetError::Source(format!("failed to open parquet file '{display}': {e}"))
145                })?;
146                self.decode(file, &display).await
147            }
148            FileTarget::S3(path) => {
149                let store = self.s3_store.as_ref().ok_or_else(|| {
150                    FaucetError::Source("parquet source: S3 store not initialised".into())
151                })?;
152                let reader = ParquetObjectReader::new(store.clone(), path.clone());
153                self.decode(reader, &display).await
154            }
155        }
156    }
157
158    async fn decode<R>(&self, reader: R, display: &str) -> Result<FileOutput, FaucetError>
159    where
160        R: parquet::arrow::async_reader::AsyncFileReader + Send + Unpin + 'static,
161    {
162        let (mut batches, arrow_schema) = self.build_batch_stream(reader, display).await?;
163
164        let mut rows: Vec<Value> = Vec::new();
165        while let Some(batch) = batches.next().await {
166            let batch = batch.map_err(|e| {
167                FaucetError::Source(format!("parquet decode error in '{display}': {e}"))
168            })?;
169            let batch_rows = record_batch_to_json(&batch)?;
170            rows.extend(batch_rows);
171        }
172
173        Ok(FileOutput {
174            path: display.to_string(),
175            rows,
176            arrow_schema,
177        })
178    }
179
180    /// Build a per-file Arrow `RecordBatch` stream from a low-level
181    /// `AsyncFileReader`. Applies the configured projection and `batch_size`
182    /// hint (skipped when `batch_size == 0`, so the file's native row-group
183    /// size governs page cadence).
184    ///
185    /// Used by both [`decode`](Self::decode) (which materialises all rows
186    /// into a `FileOutput`) and [`stream_pages`](
187    /// faucet_core::Source::stream_pages) (which yields one `StreamPage`
188    /// per `RecordBatch`).
189    async fn build_batch_stream<R>(
190        &self,
191        reader: R,
192        display: &str,
193    ) -> Result<(BatchStream, arrow::datatypes::SchemaRef), FaucetError>
194    where
195        R: parquet::arrow::async_reader::AsyncFileReader + Send + Unpin + 'static,
196    {
197        let mut builder = ParquetRecordBatchStreamBuilder::new(reader)
198            .await
199            .map_err(|e| {
200                FaucetError::Source(format!(
201                    "failed to read parquet metadata for '{display}': {e}"
202                ))
203            })?;
204
205        // `batch_size == 0` is the sentinel meaning "use the file's native
206        // row-group size as the batch cadence" — i.e. don't override the
207        // Arrow reader's default, which already yields one batch per
208        // row-group.
209        if self.config.batch_size > 0 {
210            builder = builder.with_batch_size(self.config.batch_size);
211        }
212
213        if let Some(cols) = self.config.columns.as_deref() {
214            let parquet_schema = builder.parquet_schema();
215            validate_projection(cols, parquet_schema, display)?;
216            let mask = ProjectionMask::columns(parquet_schema, cols.iter().map(String::as_str));
217            builder = builder.with_projection(mask);
218        }
219
220        let arrow_schema = builder.schema().clone();
221
222        let stream = builder.build().map_err(|e| {
223            FaucetError::Source(format!(
224                "failed to build parquet stream for '{display}': {e}"
225            ))
226        })?;
227
228        Ok((Box::pin(stream), arrow_schema))
229    }
230
231    /// Open a per-file Arrow `RecordBatch` stream for a resolved target
232    /// (local or S3), returning the boxed stream, the Arrow schema, and a
233    /// display string for error messages.
234    async fn open_target_stream(
235        &self,
236        target: &FileTarget,
237    ) -> Result<(BatchStream, arrow::datatypes::SchemaRef, String), FaucetError> {
238        let display = target.display();
239        match target {
240            FileTarget::Local(path) => {
241                let file = tokio::fs::File::open(path).await.map_err(|e| {
242                    FaucetError::Source(format!("failed to open parquet file '{display}': {e}"))
243                })?;
244                let (stream, schema) = self.build_batch_stream(file, &display).await?;
245                Ok((stream, schema, display))
246            }
247            FileTarget::S3(path) => {
248                let store = self.s3_store.as_ref().ok_or_else(|| {
249                    FaucetError::Source("parquet source: S3 store not initialised".into())
250                })?;
251                let reader = ParquetObjectReader::new(store.clone(), path.clone());
252                let (stream, schema) = self.build_batch_stream(reader, &display).await?;
253                Ok((stream, schema, display))
254            }
255        }
256    }
257}
258
259/// Boxed Arrow `RecordBatch` stream returned by
260/// [`ParquetSource::build_batch_stream`].
261type BatchStream =
262    Pin<Box<dyn futures::Stream<Item = parquet::errors::Result<arrow::array::RecordBatch>> + Send>>;
263
264#[async_trait]
265impl faucet_core::Source for ParquetSource {
266    async fn fetch_with_context(
267        &self,
268        context: &HashMap<String, Value>,
269    ) -> Result<Vec<Value>, FaucetError> {
270        let targets = self.resolve_files(context).await?;
271
272        tracing::info!(files = targets.len(), "Parquet source resolved files");
273
274        if targets.is_empty() {
275            return Ok(Vec::new());
276        }
277
278        let concurrency = self.config.concurrency.max(1);
279
280        // `buffered` (NOT `buffer_unordered`) so decoded files come back in the
281        // deterministic order `resolve_files` sorted them into — concurrency is
282        // unchanged (up to `concurrency` reads in flight), only the result order
283        // is pinned. This keeps eager-fetch row order stable and makes the
284        // schema-mismatch error name a deterministic (first, other) pair (F42).
285        let outputs: Vec<FileOutput> = stream::iter(targets)
286            .map(|target| async move {
287                let out = self.read_file(&target).await?;
288                tracing::debug!(file = %out.path, rows = out.rows.len(), "Parquet file decoded");
289                Ok::<FileOutput, FaucetError>(out)
290            })
291            .buffered(concurrency)
292            .try_collect()
293            .await?;
294
295        if outputs.len() > 1 {
296            let first = &outputs[0];
297            for other in &outputs[1..] {
298                if first.arrow_schema != other.arrow_schema {
299                    return Err(FaucetError::Source(schema_mismatch_message(first, other)));
300                }
301            }
302        }
303
304        let total: usize = outputs.iter().map(|o| o.rows.len()).sum();
305        let mut all = Vec::with_capacity(total);
306        for out in outputs {
307            all.extend(out.rows);
308        }
309
310        tracing::info!(total_records = all.len(), "Parquet source fetch complete");
311        Ok(all)
312    }
313
314    /// Stream RecordBatches from each resolved file, yielding one
315    /// [`StreamPage`] per Arrow `RecordBatch` so client-side memory is
316    /// bounded at `O(batch_size * row_width)` regardless of total file size.
317    ///
318    /// The trait-level `batch_size` argument is ignored in favour of
319    /// [`ParquetSourceConfig::batch_size`] — the config is the user-facing
320    /// knob the README documents, and routing the pipeline-supplied hint
321    /// through it would silently override an explicit config value.
322    ///
323    /// **Cadence:**
324    /// - `batch_size > 0` — passed to
325    ///   [`ParquetRecordBatchStreamBuilder::with_batch_size`]. Arrow may
326    ///   emit a *smaller* batch at row-group boundaries, so an emitted page
327    ///   can be smaller than `batch_size`.
328    /// - `batch_size == 0` — the sentinel skips `with_batch_size`, so the
329    ///   file's native row-group size drives the page cadence (one page per
330    ///   row-group).
331    ///
332    /// **Multi-file scans** (glob / S3 prefix) iterate sequentially in
333    /// sorted order. The first file's Arrow schema is the reference; any
334    /// subsequent file with a different schema surfaces as
335    /// [`FaucetError::Source`] naming both paths and the first diverging
336    /// field — matching the eager `fetch_with_context` behaviour.
337    ///
338    /// Every page carries `bookmark: None` — the Parquet source has no
339    /// incremental-replication mode.
340    fn stream_pages<'a>(
341        &'a self,
342        context: &'a HashMap<String, Value>,
343        _batch_size: usize,
344    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
345        Box::pin(async_stream::try_stream! {
346            // Resolve the FULL file set first: schema validation must cover
347            // every file even under Mode B sharding, then only this shard's
348            // subset is streamed. Validating just the shard's files would let
349            // a cross-shard schema mismatch slip through (each worker would
350            // see an internally-consistent subset) and silently mix shapes
351            // downstream — the exact failure the unsharded path rejects.
352            let all_targets = self.resolve_all_files(context).await?;
353            let targets = self.shard_filter(all_targets.clone());
354            tracing::info!(
355                files = targets.len(),
356                resolved = all_targets.len(),
357                "Parquet source resolved files",
358            );
359
360            if all_targets.is_empty() {
361                return;
362            }
363
364            // Validate every file's schema UP FRONT, before yielding any rows.
365            // A divergent schema on a *later* file must fail before earlier
366            // files' rows are committed downstream — otherwise the streaming
367            // path performs a partial, non-atomic write and only then aborts
368            // (#146 M11). Opening a target reads just the Parquet footer
369            // metadata (not row data), so this is a cheap probe; we drop each
370            // probe stream immediately. The cost is one extra footer read per
371            // file, paid once before streaming begins.
372            let mut reference: Option<(String, arrow::datatypes::SchemaRef)> = None;
373            for target in &all_targets {
374                let (_, arrow_schema, display) = self.open_target_stream(target).await?;
375                if let Some((first_path, first_schema)) = &reference {
376                    if first_schema != &arrow_schema {
377                        Err(FaucetError::Source(schema_mismatch_message_pair(
378                            first_path,
379                            first_schema,
380                            &display,
381                            &arrow_schema,
382                        )))?;
383                    }
384                } else {
385                    reference = Some((display, arrow_schema));
386                }
387            }
388
389            // Schemas are consistent across all files; stream rows file by file.
390            let mut total_records = 0usize;
391            let mut total_pages = 0usize;
392            for target in &targets {
393                let (mut batches, _schema, display) = self.open_target_stream(target).await?;
394                while let Some(batch) = batches.next().await {
395                    let batch = batch.map_err(|e| {
396                        FaucetError::Source(format!(
397                            "parquet decode error in '{display}': {e}"
398                        ))
399                    })?;
400                    let rows = record_batch_to_json(&batch)?;
401                    if rows.is_empty() {
402                        continue;
403                    }
404                    total_records += rows.len();
405                    total_pages += 1;
406                    yield StreamPage { records: rows, bookmark: None };
407                }
408            }
409
410            tracing::info!(
411                pages = total_pages,
412                total_records,
413                batch_size = self.config.batch_size,
414                "Parquet source stream complete",
415            );
416        })
417    }
418
419    /// The Parquet source natively produces Arrow `RecordBatch`es, so it opts
420    /// into the columnar fast path (RFC 0002 / #375). A `parquet -> parquet`
421    /// chain can then move batches end-to-end without materializing
422    /// `serde_json::Value` rows.
423    #[cfg(feature = "arrow")]
424    fn supports_columnar(&self) -> bool {
425        true
426    }
427
428    /// Stream Arrow `RecordBatch`es directly, one
429    /// [`ColumnarPage`](faucet_core::columnar::ColumnarPage) per batch. Mirrors
430    /// [`stream_pages`](faucet_core::Source::stream_pages) exactly — same file
431    /// resolution, same up-front cross-file schema validation, same file-by-file
432    /// iteration — but skips the `RecordBatch` → JSON conversion entirely.
433    ///
434    /// Every page carries `bookmark: None` (the Parquet source has no
435    /// incremental-replication mode). Empty batches are skipped.
436    #[cfg(feature = "arrow")]
437    fn stream_batches<'a>(
438        &'a self,
439        context: &'a HashMap<String, Value>,
440        _batch_size: usize,
441    ) -> Pin<
442        Box<
443            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
444        >,
445    > {
446        Box::pin(async_stream::try_stream! {
447            // Resolve the FULL file set first: schema validation must cover
448            // every file even under Mode B sharding, then only this shard's
449            // subset is streamed. Validating just the shard's files would let
450            // a cross-shard schema mismatch slip through (each worker would
451            // see an internally-consistent subset) and silently mix shapes
452            // downstream — the exact failure the unsharded path rejects.
453            let all_targets = self.resolve_all_files(context).await?;
454            let targets = self.shard_filter(all_targets.clone());
455            tracing::info!(
456                files = targets.len(),
457                resolved = all_targets.len(),
458                "Parquet source resolved files",
459            );
460
461            if all_targets.is_empty() {
462                return;
463            }
464
465            // Validate every file's schema UP FRONT, before yielding any rows.
466            // A divergent schema on a *later* file must fail before earlier
467            // files' rows are committed downstream — otherwise the streaming
468            // path performs a partial, non-atomic write and only then aborts
469            // (#146 M11). Opening a target reads just the Parquet footer
470            // metadata (not row data), so this is a cheap probe; we drop each
471            // probe stream immediately. The cost is one extra footer read per
472            // file, paid once before streaming begins.
473            let mut reference: Option<(String, arrow::datatypes::SchemaRef)> = None;
474            for target in &all_targets {
475                let (_, arrow_schema, display) = self.open_target_stream(target).await?;
476                if let Some((first_path, first_schema)) = &reference {
477                    if first_schema != &arrow_schema {
478                        Err(FaucetError::Source(schema_mismatch_message_pair(
479                            first_path,
480                            first_schema,
481                            &display,
482                            &arrow_schema,
483                        )))?;
484                    }
485                } else {
486                    reference = Some((display, arrow_schema));
487                }
488            }
489
490            // Schemas are consistent across all files; stream batches file by file.
491            let mut total_records = 0usize;
492            let mut total_pages = 0usize;
493            for target in &targets {
494                let (mut batches, _schema, display) = self.open_target_stream(target).await?;
495                while let Some(batch) = batches.next().await {
496                    let batch = batch.map_err(|e| {
497                        FaucetError::Source(format!(
498                            "parquet decode error in '{display}': {e}"
499                        ))
500                    })?;
501                    if batch.num_rows() == 0 {
502                        continue;
503                    }
504                    total_records += batch.num_rows();
505                    total_pages += 1;
506                    yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
507                }
508            }
509
510            tracing::info!(
511                pages = total_pages,
512                total_records,
513                batch_size = self.config.batch_size,
514                "Parquet source columnar stream complete",
515            );
516        })
517    }
518
519    fn config_schema(&self) -> Value {
520        serde_json::to_value(faucet_core::schema_for!(ParquetSourceConfig))
521            .expect("schema serialization")
522    }
523
524    fn dataset_uri(&self) -> String {
525        use crate::config::ParquetLocation;
526        match &self.config.source {
527            ParquetLocation::LocalPath { path } => format!("file://{path}"),
528            ParquetLocation::Glob { pattern } => format!("file://{pattern}"),
529            ParquetLocation::S3(s3) => match (&s3.key, &s3.prefix) {
530                (Some(k), _) => format!("s3://{}/{}", s3.bucket, k),
531                (_, Some(p)) => format!("s3://{}/{}", s3.bucket, p),
532                _ => format!("s3://{}", s3.bucket),
533            },
534        }
535    }
536
537    /// The Parquet source is always shardable: any resolved file set (glob or
538    /// S3 prefix) can be split by hash-of-path. Sharding only takes effect when
539    /// the cluster coordinator calls `apply_shard`; a plain `faucet run` reads
540    /// every file. A single-file source still enumerates — the extra shards
541    /// simply resolve to zero files.
542    fn is_shardable(&self) -> bool {
543        true
544    }
545
546    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
547    /// files whose path hashes to `i (mod target)`. No I/O: the partition is
548    /// defined by the hash function, so enumeration is cheap and stable as new
549    /// files appear. `target <= 1` yields a single whole-dataset shard.
550    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
551        Ok(plan_hash_shards(target))
552    }
553
554    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
555    /// clears any filter (reads every file).
556    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
557        *self.applied_shard.lock().expect("shard mutex poisoned") =
558            parse_hash_shard(shard, "parquet")?;
559        Ok(())
560    }
561}
562
563/// Per-file decode output, kept around long enough to validate cross-file
564/// schema consistency.
565struct FileOutput {
566    path: String,
567    rows: Vec<Value>,
568    arrow_schema: arrow::datatypes::SchemaRef,
569}
570
571/// Resolved file location ready for reading.
572#[derive(Debug, Clone)]
573enum FileTarget {
574    Local(PathBuf),
575    S3(ObjectPath),
576}
577
578impl FileTarget {
579    fn display(&self) -> String {
580        match self {
581            FileTarget::Local(p) => p.display().to_string(),
582            FileTarget::S3(p) => format!("s3://{p}"),
583        }
584    }
585}
586
587/// Apply context substitution only when there is something to substitute.
588fn substitute(template: &str, context: &HashMap<String, Value>) -> String {
589    if context.is_empty() {
590        template.to_string()
591    } else {
592        faucet_core::util::substitute_context(template, context)
593    }
594}
595
596/// Expand a glob pattern into a sorted list of local file paths.
597fn expand_glob(pattern: &str) -> Result<Vec<FileTarget>, FaucetError> {
598    let entries = glob::glob(pattern)
599        .map_err(|e| FaucetError::Config(format!("invalid glob '{pattern}': {e}")))?;
600
601    let mut paths = Vec::new();
602    for entry in entries {
603        let p = entry
604            .map_err(|e| FaucetError::Source(format!("glob entry error for '{pattern}': {e}")))?;
605        if p.is_file() {
606            paths.push(p);
607        }
608    }
609    paths.sort();
610    Ok(paths.into_iter().map(FileTarget::Local).collect())
611}
612
613/// List S3 objects under `prefix` and turn them into `FileTarget::S3` entries.
614async fn list_s3_prefix(
615    store: &dyn ObjectStore,
616    prefix: &str,
617) -> Result<Vec<FileTarget>, FaucetError> {
618    let prefix_path = if prefix.is_empty() {
619        None
620    } else {
621        Some(ObjectPath::from(prefix))
622    };
623
624    let mut listing = store.list(prefix_path.as_ref());
625    let mut keys = Vec::new();
626    while let Some(item) = listing.next().await {
627        let meta = item.map_err(|e| {
628            FaucetError::Source(format!("S3 list error for prefix '{prefix}': {e}"))
629        })?;
630        keys.push(meta.location);
631    }
632    keys.sort();
633    Ok(keys.into_iter().map(FileTarget::S3).collect())
634}
635
636/// Build an `AmazonS3` `object_store` client from a `ParquetS3Config`.
637fn build_s3_store(s3: &ParquetS3Config) -> Result<Arc<dyn ObjectStore>, FaucetError> {
638    if s3.bucket.trim().is_empty() {
639        return Err(FaucetError::Config(
640            "parquet source: S3 bucket must not be empty".into(),
641        ));
642    }
643
644    let mut builder = AmazonS3Builder::from_env().with_bucket_name(&s3.bucket);
645    if let Some(region) = &s3.region {
646        builder = builder.with_region(region);
647    }
648    if let Some(endpoint) = &s3.endpoint_url {
649        builder = builder.with_endpoint(endpoint);
650        if endpoint.starts_with("http://") {
651            builder = builder.with_allow_http(true);
652        }
653    }
654
655    let store = builder
656        .build()
657        .map_err(|e| FaucetError::Config(format!("failed to build S3 client: {e}")))?;
658    Ok(Arc::new(store))
659}
660
661/// Verify every requested column exists in the file's Parquet schema. The
662/// `ProjectionMask::columns` API silently ignores unknown names, so we
663/// pre-validate here to surface a clear error to the caller.
664fn validate_projection(
665    requested: &[String],
666    parquet_schema: &parquet::schema::types::SchemaDescriptor,
667    display: &str,
668) -> Result<(), FaucetError> {
669    let root = parquet_schema.root_schema();
670    let parquet::schema::types::Type::GroupType { fields, .. } = root else {
671        return Err(FaucetError::Source(format!(
672            "parquet root schema for '{display}' is not a group"
673        )));
674    };
675
676    let known: std::collections::HashSet<&str> = fields.iter().map(|f| f.name()).collect();
677
678    for name in requested {
679        if !known.contains(name.as_str()) {
680            return Err(FaucetError::Source(format!(
681                "parquet source: projected column '{name}' not found in file '{display}' \
682                 (available: {})",
683                known.iter().copied().collect::<Vec<_>>().join(", ")
684            )));
685        }
686    }
687
688    Ok(())
689}
690
691/// Compose a descriptive cross-file schema mismatch error.
692fn schema_mismatch_message(first: &FileOutput, other: &FileOutput) -> String {
693    schema_mismatch_message_pair(
694        &first.path,
695        &first.arrow_schema,
696        &other.path,
697        &other.arrow_schema,
698    )
699}
700
701/// Same as [`schema_mismatch_message`] but works on raw `(path, schema)`
702/// pairs so it can be called from the streaming path where no `FileOutput`
703/// exists.
704fn schema_mismatch_message_pair(
705    first_path: &str,
706    first_schema: &arrow::datatypes::SchemaRef,
707    other_path: &str,
708    other_schema: &arrow::datatypes::SchemaRef,
709) -> String {
710    let first_fields: Vec<String> = first_schema
711        .fields()
712        .iter()
713        .map(|f| format!("{}:{}", f.name(), f.data_type()))
714        .collect();
715    let other_fields: Vec<String> = other_schema
716        .fields()
717        .iter()
718        .map(|f| format!("{}:{}", f.name(), f.data_type()))
719        .collect();
720
721    // Identify the first diverging field for a focused hint.
722    let max_len = first_fields.len().max(other_fields.len());
723    let mut first_diff = None;
724    for i in 0..max_len {
725        let a = first_fields
726            .get(i)
727            .map(String::as_str)
728            .unwrap_or("<missing>");
729        let b = other_fields
730            .get(i)
731            .map(String::as_str)
732            .unwrap_or("<missing>");
733        if a != b {
734            first_diff = Some((i, a.to_string(), b.to_string()));
735            break;
736        }
737    }
738
739    let detail = match first_diff {
740        Some((i, a, b)) => format!(" (field #{i}: '{a}' vs '{b}')"),
741        None => String::new(),
742    };
743
744    format!("parquet source: schema mismatch between '{first_path}' and '{other_path}'{detail}")
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use crate::config::ParquetSourceConfig;
751    use faucet_core::Source;
752
753    #[test]
754    fn substitute_passes_through_when_context_empty() {
755        let ctx = HashMap::new();
756        assert_eq!(substitute("/tmp/{x}.parquet", &ctx), "/tmp/{x}.parquet");
757    }
758
759    #[test]
760    fn substitute_replaces_placeholders() {
761        let mut ctx = HashMap::new();
762        ctx.insert("region".to_string(), Value::String("us".into()));
763        assert_eq!(
764            substitute("data/{region}/x.parquet", &ctx),
765            "data/us/x.parquet"
766        );
767    }
768
769    #[tokio::test]
770    async fn accepts_zero_batch_size_as_sentinel() {
771        // `batch_size = 0` is the "no batching" sentinel — page cadence
772        // falls back to the file's native row-group size. The source
773        // constructor must accept it.
774        let cfg = ParquetSourceConfig::local("/tmp/x.parquet").batch_size(0);
775        let source = ParquetSource::new(cfg)
776            .await
777            .expect("batch_size=0 must be accepted as the no-batching sentinel");
778        assert_eq!(source.config.batch_size, 0);
779    }
780
781    #[tokio::test]
782    async fn rejects_zero_concurrency() {
783        let cfg = ParquetSourceConfig::local("/tmp/x.parquet").concurrency(0);
784        match ParquetSource::new(cfg).await {
785            Err(FaucetError::Config(msg)) => assert!(msg.contains("concurrency")),
786            other => panic!("expected Config error, got {:?}", other.err()),
787        }
788    }
789
790    #[tokio::test]
791    async fn rejects_s3_with_both_key_and_prefix() {
792        let mut s3 = ParquetS3Config::object("b", "k.parquet");
793        s3.prefix = Some("p/".into());
794        let cfg = ParquetSourceConfig::s3(s3);
795        let source = ParquetSource::new(cfg).await.unwrap();
796        let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
797        assert!(matches!(err, FaucetError::Config(_)));
798    }
799
800    #[tokio::test]
801    async fn rejects_s3_with_neither_key_nor_prefix() {
802        let s3 = ParquetS3Config {
803            bucket: "b".into(),
804            key: None,
805            prefix: None,
806            region: None,
807            endpoint_url: None,
808        };
809        let cfg = ParquetSourceConfig::s3(s3);
810        let source = ParquetSource::new(cfg).await.unwrap();
811        let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
812        assert!(matches!(err, FaucetError::Config(_)));
813    }
814
815    #[test]
816    fn empty_bucket_rejected() {
817        let s3 = ParquetS3Config::object("", "k.parquet");
818        let err = build_s3_store(&s3).unwrap_err();
819        assert!(matches!(err, FaucetError::Config(_)));
820    }
821
822    #[tokio::test]
823    async fn dataset_uri_local_path() {
824        let cfg = ParquetSourceConfig::local("/tmp/data.parquet");
825        let source = ParquetSource::new(cfg).await.unwrap();
826        assert_eq!(source.dataset_uri(), "file:///tmp/data.parquet");
827    }
828
829    #[tokio::test]
830    async fn dataset_uri_glob() {
831        let cfg = ParquetSourceConfig::glob("/tmp/data/*.parquet");
832        let source = ParquetSource::new(cfg).await.unwrap();
833        assert_eq!(source.dataset_uri(), "file:///tmp/data/*.parquet");
834    }
835
836    #[tokio::test]
837    async fn dataset_uri_s3_with_key() {
838        let s3 = ParquetS3Config::object("my-bucket", "path/to/file.parquet");
839        let cfg = ParquetSourceConfig::s3(s3);
840        let source = ParquetSource::new(cfg).await.unwrap();
841        assert_eq!(source.dataset_uri(), "s3://my-bucket/path/to/file.parquet");
842    }
843
844    // ── Hash-modulo file sharding (Mode B, #262) ─────────────────────────────
845
846    /// Create `n` empty `part-XX.parquet` files in a temp dir and return the
847    /// dir. `resolve_files` only globs (no footer read), so empty files are
848    /// enough to exercise the shard partition logic.
849    fn glob_fixture(n: usize) -> tempfile::TempDir {
850        let dir = tempfile::tempdir().expect("tempdir");
851        for i in 0..n {
852            std::fs::write(dir.path().join(format!("part-{i:02}.parquet")), b"").expect("touch");
853        }
854        dir
855    }
856
857    /// The union of every shard's resolved file set equals the full set, with
858    /// no file in two shards — the core no-dup / no-loss guarantee.
859    #[tokio::test]
860    async fn shards_partition_resolved_files_disjointly_and_completely() {
861        let dir = glob_fixture(12);
862        let pattern = format!("{}/*.parquet", dir.path().display());
863        let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
864            .await
865            .unwrap();
866
867        assert!(source.is_shardable());
868        let shards = source.enumerate_shards(3).await.unwrap();
869        assert_eq!(shards.len(), 3);
870
871        let ctx = HashMap::new();
872        let all: Vec<String> = source
873            .resolve_files(&ctx)
874            .await
875            .unwrap()
876            .iter()
877            .map(FileTarget::display)
878            .collect();
879        assert_eq!(all.len(), 12);
880
881        let mut union: Vec<String> = Vec::new();
882        for shard in &shards {
883            source.apply_shard(shard).await.unwrap();
884            union.extend(
885                source
886                    .resolve_files(&ctx)
887                    .await
888                    .unwrap()
889                    .iter()
890                    .map(FileTarget::display),
891            );
892        }
893        union.sort();
894        let mut expected = all.clone();
895        expected.sort();
896        assert_eq!(
897            union, expected,
898            "shards must union to the full file set, disjointly"
899        );
900    }
901
902    #[tokio::test]
903    async fn whole_shard_and_target_one_read_everything() {
904        let dir = glob_fixture(4);
905        let pattern = format!("{}/*.parquet", dir.path().display());
906        let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
907            .await
908            .unwrap();
909
910        // target <= 1 enumerates to the single whole-dataset shard.
911        let shards = source.enumerate_shards(1).await.unwrap();
912        assert_eq!(shards.len(), 1);
913        assert!(shards[0].is_whole());
914
915        let ctx = HashMap::new();
916        source.apply_shard(&shards[0]).await.unwrap();
917        assert_eq!(source.resolve_files(&ctx).await.unwrap().len(), 4);
918    }
919
920    #[tokio::test]
921    async fn apply_shard_rejects_malformed_descriptor() {
922        let source = ParquetSource::new(ParquetSourceConfig::local("/tmp/x.parquet"))
923            .await
924            .unwrap();
925        let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "index": 0 }));
926        assert!(source.apply_shard(&bad).await.is_err());
927    }
928}