1use 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
26pub struct ParquetSource {
28 config: ParquetSourceConfig,
29 s3_store: Option<Arc<dyn ObjectStore>>,
32 applied_shard: Mutex<Option<HashShard>>,
36}
37
38impl ParquetSource {
39 pub async fn new(config: ParquetSourceConfig) -> Result<Self, FaucetError> {
45 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 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 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 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 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 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 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 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
259type 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 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 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 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 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 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 fn is_shardable(&self) -> bool {
443 true
444 }
445
446 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
451 Ok(plan_hash_shards(target))
452 }
453
454 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
463struct FileOutput {
466 path: String,
467 rows: Vec<Value>,
468 arrow_schema: arrow::datatypes::SchemaRef,
469}
470
471#[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
487fn 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
496fn 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
513async 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
536fn 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
561fn 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
591fn 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
601fn 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 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 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 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 #[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 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}