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 #[cfg(feature = "arrow")]
424 fn supports_columnar(&self) -> bool {
425 true
426 }
427
428 #[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 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 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 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 fn is_shardable(&self) -> bool {
543 true
544 }
545
546 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
551 Ok(plan_hash_shards(target))
552 }
553
554 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
563struct FileOutput {
566 path: String,
567 rows: Vec<Value>,
568 arrow_schema: arrow::datatypes::SchemaRef,
569}
570
571#[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
587fn 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
596fn 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
613async 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
636fn 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
661fn 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
691fn 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
701fn 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 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 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 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 #[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 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}