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    fn config_schema(&self) -> Value {
420        serde_json::to_value(faucet_core::schema_for!(ParquetSourceConfig))
421            .expect("schema serialization")
422    }
423
424    fn dataset_uri(&self) -> String {
425        use crate::config::ParquetLocation;
426        match &self.config.source {
427            ParquetLocation::LocalPath { path } => format!("file://{path}"),
428            ParquetLocation::Glob { pattern } => format!("file://{pattern}"),
429            ParquetLocation::S3(s3) => match (&s3.key, &s3.prefix) {
430                (Some(k), _) => format!("s3://{}/{}", s3.bucket, k),
431                (_, Some(p)) => format!("s3://{}/{}", s3.bucket, p),
432                _ => format!("s3://{}", s3.bucket),
433            },
434        }
435    }
436
437    /// The Parquet source is always shardable: any resolved file set (glob or
438    /// S3 prefix) can be split by hash-of-path. Sharding only takes effect when
439    /// the cluster coordinator calls `apply_shard`; a plain `faucet run` reads
440    /// every file. A single-file source still enumerates — the extra shards
441    /// simply resolve to zero files.
442    fn is_shardable(&self) -> bool {
443        true
444    }
445
446    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
447    /// files whose path hashes to `i (mod target)`. No I/O: the partition is
448    /// defined by the hash function, so enumeration is cheap and stable as new
449    /// files appear. `target <= 1` yields a single whole-dataset shard.
450    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
451        Ok(plan_hash_shards(target))
452    }
453
454    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
455    /// clears any filter (reads every file).
456    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
457        *self.applied_shard.lock().expect("shard mutex poisoned") =
458            parse_hash_shard(shard, "parquet")?;
459        Ok(())
460    }
461}
462
463/// Per-file decode output, kept around long enough to validate cross-file
464/// schema consistency.
465struct FileOutput {
466    path: String,
467    rows: Vec<Value>,
468    arrow_schema: arrow::datatypes::SchemaRef,
469}
470
471/// Resolved file location ready for reading.
472#[derive(Debug, Clone)]
473enum FileTarget {
474    Local(PathBuf),
475    S3(ObjectPath),
476}
477
478impl FileTarget {
479    fn display(&self) -> String {
480        match self {
481            FileTarget::Local(p) => p.display().to_string(),
482            FileTarget::S3(p) => format!("s3://{p}"),
483        }
484    }
485}
486
487/// Apply context substitution only when there is something to substitute.
488fn substitute(template: &str, context: &HashMap<String, Value>) -> String {
489    if context.is_empty() {
490        template.to_string()
491    } else {
492        faucet_core::util::substitute_context(template, context)
493    }
494}
495
496/// Expand a glob pattern into a sorted list of local file paths.
497fn expand_glob(pattern: &str) -> Result<Vec<FileTarget>, FaucetError> {
498    let entries = glob::glob(pattern)
499        .map_err(|e| FaucetError::Config(format!("invalid glob '{pattern}': {e}")))?;
500
501    let mut paths = Vec::new();
502    for entry in entries {
503        let p = entry
504            .map_err(|e| FaucetError::Source(format!("glob entry error for '{pattern}': {e}")))?;
505        if p.is_file() {
506            paths.push(p);
507        }
508    }
509    paths.sort();
510    Ok(paths.into_iter().map(FileTarget::Local).collect())
511}
512
513/// List S3 objects under `prefix` and turn them into `FileTarget::S3` entries.
514async fn list_s3_prefix(
515    store: &dyn ObjectStore,
516    prefix: &str,
517) -> Result<Vec<FileTarget>, FaucetError> {
518    let prefix_path = if prefix.is_empty() {
519        None
520    } else {
521        Some(ObjectPath::from(prefix))
522    };
523
524    let mut listing = store.list(prefix_path.as_ref());
525    let mut keys = Vec::new();
526    while let Some(item) = listing.next().await {
527        let meta = item.map_err(|e| {
528            FaucetError::Source(format!("S3 list error for prefix '{prefix}': {e}"))
529        })?;
530        keys.push(meta.location);
531    }
532    keys.sort();
533    Ok(keys.into_iter().map(FileTarget::S3).collect())
534}
535
536/// Build an `AmazonS3` `object_store` client from a `ParquetS3Config`.
537fn build_s3_store(s3: &ParquetS3Config) -> Result<Arc<dyn ObjectStore>, FaucetError> {
538    if s3.bucket.trim().is_empty() {
539        return Err(FaucetError::Config(
540            "parquet source: S3 bucket must not be empty".into(),
541        ));
542    }
543
544    let mut builder = AmazonS3Builder::from_env().with_bucket_name(&s3.bucket);
545    if let Some(region) = &s3.region {
546        builder = builder.with_region(region);
547    }
548    if let Some(endpoint) = &s3.endpoint_url {
549        builder = builder.with_endpoint(endpoint);
550        if endpoint.starts_with("http://") {
551            builder = builder.with_allow_http(true);
552        }
553    }
554
555    let store = builder
556        .build()
557        .map_err(|e| FaucetError::Config(format!("failed to build S3 client: {e}")))?;
558    Ok(Arc::new(store))
559}
560
561/// Verify every requested column exists in the file's Parquet schema. The
562/// `ProjectionMask::columns` API silently ignores unknown names, so we
563/// pre-validate here to surface a clear error to the caller.
564fn validate_projection(
565    requested: &[String],
566    parquet_schema: &parquet::schema::types::SchemaDescriptor,
567    display: &str,
568) -> Result<(), FaucetError> {
569    let root = parquet_schema.root_schema();
570    let parquet::schema::types::Type::GroupType { fields, .. } = root else {
571        return Err(FaucetError::Source(format!(
572            "parquet root schema for '{display}' is not a group"
573        )));
574    };
575
576    let known: std::collections::HashSet<&str> = fields.iter().map(|f| f.name()).collect();
577
578    for name in requested {
579        if !known.contains(name.as_str()) {
580            return Err(FaucetError::Source(format!(
581                "parquet source: projected column '{name}' not found in file '{display}' \
582                 (available: {})",
583                known.iter().copied().collect::<Vec<_>>().join(", ")
584            )));
585        }
586    }
587
588    Ok(())
589}
590
591/// Compose a descriptive cross-file schema mismatch error.
592fn schema_mismatch_message(first: &FileOutput, other: &FileOutput) -> String {
593    schema_mismatch_message_pair(
594        &first.path,
595        &first.arrow_schema,
596        &other.path,
597        &other.arrow_schema,
598    )
599}
600
601/// Same as [`schema_mismatch_message`] but works on raw `(path, schema)`
602/// pairs so it can be called from the streaming path where no `FileOutput`
603/// exists.
604fn schema_mismatch_message_pair(
605    first_path: &str,
606    first_schema: &arrow::datatypes::SchemaRef,
607    other_path: &str,
608    other_schema: &arrow::datatypes::SchemaRef,
609) -> String {
610    let first_fields: Vec<String> = first_schema
611        .fields()
612        .iter()
613        .map(|f| format!("{}:{}", f.name(), f.data_type()))
614        .collect();
615    let other_fields: Vec<String> = other_schema
616        .fields()
617        .iter()
618        .map(|f| format!("{}:{}", f.name(), f.data_type()))
619        .collect();
620
621    // Identify the first diverging field for a focused hint.
622    let max_len = first_fields.len().max(other_fields.len());
623    let mut first_diff = None;
624    for i in 0..max_len {
625        let a = first_fields
626            .get(i)
627            .map(String::as_str)
628            .unwrap_or("<missing>");
629        let b = other_fields
630            .get(i)
631            .map(String::as_str)
632            .unwrap_or("<missing>");
633        if a != b {
634            first_diff = Some((i, a.to_string(), b.to_string()));
635            break;
636        }
637    }
638
639    let detail = match first_diff {
640        Some((i, a, b)) => format!(" (field #{i}: '{a}' vs '{b}')"),
641        None => String::new(),
642    };
643
644    format!("parquet source: schema mismatch between '{first_path}' and '{other_path}'{detail}")
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::config::ParquetSourceConfig;
651    use faucet_core::Source;
652
653    #[test]
654    fn substitute_passes_through_when_context_empty() {
655        let ctx = HashMap::new();
656        assert_eq!(substitute("/tmp/{x}.parquet", &ctx), "/tmp/{x}.parquet");
657    }
658
659    #[test]
660    fn substitute_replaces_placeholders() {
661        let mut ctx = HashMap::new();
662        ctx.insert("region".to_string(), Value::String("us".into()));
663        assert_eq!(
664            substitute("data/{region}/x.parquet", &ctx),
665            "data/us/x.parquet"
666        );
667    }
668
669    #[tokio::test]
670    async fn accepts_zero_batch_size_as_sentinel() {
671        // `batch_size = 0` is the "no batching" sentinel — page cadence
672        // falls back to the file's native row-group size. The source
673        // constructor must accept it.
674        let cfg = ParquetSourceConfig::local("/tmp/x.parquet").batch_size(0);
675        let source = ParquetSource::new(cfg)
676            .await
677            .expect("batch_size=0 must be accepted as the no-batching sentinel");
678        assert_eq!(source.config.batch_size, 0);
679    }
680
681    #[tokio::test]
682    async fn rejects_zero_concurrency() {
683        let cfg = ParquetSourceConfig::local("/tmp/x.parquet").concurrency(0);
684        match ParquetSource::new(cfg).await {
685            Err(FaucetError::Config(msg)) => assert!(msg.contains("concurrency")),
686            other => panic!("expected Config error, got {:?}", other.err()),
687        }
688    }
689
690    #[tokio::test]
691    async fn rejects_s3_with_both_key_and_prefix() {
692        let mut s3 = ParquetS3Config::object("b", "k.parquet");
693        s3.prefix = Some("p/".into());
694        let cfg = ParquetSourceConfig::s3(s3);
695        let source = ParquetSource::new(cfg).await.unwrap();
696        let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
697        assert!(matches!(err, FaucetError::Config(_)));
698    }
699
700    #[tokio::test]
701    async fn rejects_s3_with_neither_key_nor_prefix() {
702        let s3 = ParquetS3Config {
703            bucket: "b".into(),
704            key: None,
705            prefix: None,
706            region: None,
707            endpoint_url: None,
708        };
709        let cfg = ParquetSourceConfig::s3(s3);
710        let source = ParquetSource::new(cfg).await.unwrap();
711        let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
712        assert!(matches!(err, FaucetError::Config(_)));
713    }
714
715    #[test]
716    fn empty_bucket_rejected() {
717        let s3 = ParquetS3Config::object("", "k.parquet");
718        let err = build_s3_store(&s3).unwrap_err();
719        assert!(matches!(err, FaucetError::Config(_)));
720    }
721
722    #[tokio::test]
723    async fn dataset_uri_local_path() {
724        let cfg = ParquetSourceConfig::local("/tmp/data.parquet");
725        let source = ParquetSource::new(cfg).await.unwrap();
726        assert_eq!(source.dataset_uri(), "file:///tmp/data.parquet");
727    }
728
729    #[tokio::test]
730    async fn dataset_uri_glob() {
731        let cfg = ParquetSourceConfig::glob("/tmp/data/*.parquet");
732        let source = ParquetSource::new(cfg).await.unwrap();
733        assert_eq!(source.dataset_uri(), "file:///tmp/data/*.parquet");
734    }
735
736    #[tokio::test]
737    async fn dataset_uri_s3_with_key() {
738        let s3 = ParquetS3Config::object("my-bucket", "path/to/file.parquet");
739        let cfg = ParquetSourceConfig::s3(s3);
740        let source = ParquetSource::new(cfg).await.unwrap();
741        assert_eq!(source.dataset_uri(), "s3://my-bucket/path/to/file.parquet");
742    }
743
744    // ── Hash-modulo file sharding (Mode B, #262) ─────────────────────────────
745
746    /// Create `n` empty `part-XX.parquet` files in a temp dir and return the
747    /// dir. `resolve_files` only globs (no footer read), so empty files are
748    /// enough to exercise the shard partition logic.
749    fn glob_fixture(n: usize) -> tempfile::TempDir {
750        let dir = tempfile::tempdir().expect("tempdir");
751        for i in 0..n {
752            std::fs::write(dir.path().join(format!("part-{i:02}.parquet")), b"").expect("touch");
753        }
754        dir
755    }
756
757    /// The union of every shard's resolved file set equals the full set, with
758    /// no file in two shards — the core no-dup / no-loss guarantee.
759    #[tokio::test]
760    async fn shards_partition_resolved_files_disjointly_and_completely() {
761        let dir = glob_fixture(12);
762        let pattern = format!("{}/*.parquet", dir.path().display());
763        let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
764            .await
765            .unwrap();
766
767        assert!(source.is_shardable());
768        let shards = source.enumerate_shards(3).await.unwrap();
769        assert_eq!(shards.len(), 3);
770
771        let ctx = HashMap::new();
772        let all: Vec<String> = source
773            .resolve_files(&ctx)
774            .await
775            .unwrap()
776            .iter()
777            .map(FileTarget::display)
778            .collect();
779        assert_eq!(all.len(), 12);
780
781        let mut union: Vec<String> = Vec::new();
782        for shard in &shards {
783            source.apply_shard(shard).await.unwrap();
784            union.extend(
785                source
786                    .resolve_files(&ctx)
787                    .await
788                    .unwrap()
789                    .iter()
790                    .map(FileTarget::display),
791            );
792        }
793        union.sort();
794        let mut expected = all.clone();
795        expected.sort();
796        assert_eq!(
797            union, expected,
798            "shards must union to the full file set, disjointly"
799        );
800    }
801
802    #[tokio::test]
803    async fn whole_shard_and_target_one_read_everything() {
804        let dir = glob_fixture(4);
805        let pattern = format!("{}/*.parquet", dir.path().display());
806        let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
807            .await
808            .unwrap();
809
810        // target <= 1 enumerates to the single whole-dataset shard.
811        let shards = source.enumerate_shards(1).await.unwrap();
812        assert_eq!(shards.len(), 1);
813        assert!(shards[0].is_whole());
814
815        let ctx = HashMap::new();
816        source.apply_shard(&shards[0]).await.unwrap();
817        assert_eq!(source.resolve_files(&ctx).await.unwrap().len(), 4);
818    }
819
820    #[tokio::test]
821    async fn apply_shard_rejects_malformed_descriptor() {
822        let source = ParquetSource::new(ParquetSourceConfig::local("/tmp/x.parquet"))
823            .await
824            .unwrap();
825        let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "index": 0 }));
826        assert!(source.apply_shard(&bad).await.is_err());
827    }
828}