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 connector_name(&self) -> &'static str {
520 "parquet"
521 }
522
523 fn config_schema(&self) -> Value {
524 serde_json::to_value(faucet_core::schema_for!(ParquetSourceConfig))
525 .expect("schema serialization")
526 }
527
528 fn dataset_uri(&self) -> String {
529 use crate::config::ParquetLocation;
530 match &self.config.source {
531 ParquetLocation::LocalPath { path } => format!("file://{path}"),
532 ParquetLocation::Glob { pattern } => format!("file://{pattern}"),
533 ParquetLocation::S3(s3) => match (&s3.key, &s3.prefix) {
534 (Some(k), _) => format!("s3://{}/{}", s3.bucket, k),
535 (_, Some(p)) => format!("s3://{}/{}", s3.bucket, p),
536 _ => format!("s3://{}", s3.bucket),
537 },
538 }
539 }
540
541 fn is_shardable(&self) -> bool {
547 true
548 }
549
550 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
555 Ok(plan_hash_shards(target))
556 }
557
558 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
561 *self.applied_shard.lock().expect("shard mutex poisoned") =
562 parse_hash_shard(shard, "parquet")?;
563 Ok(())
564 }
565}
566
567struct FileOutput {
570 path: String,
571 rows: Vec<Value>,
572 arrow_schema: arrow::datatypes::SchemaRef,
573}
574
575#[derive(Debug, Clone)]
577enum FileTarget {
578 Local(PathBuf),
579 S3(ObjectPath),
580}
581
582impl FileTarget {
583 fn display(&self) -> String {
584 match self {
585 FileTarget::Local(p) => p.display().to_string(),
586 FileTarget::S3(p) => format!("s3://{p}"),
587 }
588 }
589}
590
591fn substitute(template: &str, context: &HashMap<String, Value>) -> String {
593 if context.is_empty() {
594 template.to_string()
595 } else {
596 faucet_core::util::substitute_context(template, context)
597 }
598}
599
600fn expand_glob(pattern: &str) -> Result<Vec<FileTarget>, FaucetError> {
602 let entries = glob::glob(pattern)
603 .map_err(|e| FaucetError::Config(format!("invalid glob '{pattern}': {e}")))?;
604
605 let mut paths = Vec::new();
606 for entry in entries {
607 let p = entry
608 .map_err(|e| FaucetError::Source(format!("glob entry error for '{pattern}': {e}")))?;
609 if p.is_file() {
610 paths.push(p);
611 }
612 }
613 paths.sort();
614 Ok(paths.into_iter().map(FileTarget::Local).collect())
615}
616
617async fn list_s3_prefix(
619 store: &dyn ObjectStore,
620 prefix: &str,
621) -> Result<Vec<FileTarget>, FaucetError> {
622 let prefix_path = if prefix.is_empty() {
623 None
624 } else {
625 Some(ObjectPath::from(prefix))
626 };
627
628 let mut listing = store.list(prefix_path.as_ref());
629 let mut keys = Vec::new();
630 while let Some(item) = listing.next().await {
631 let meta = item.map_err(|e| {
632 FaucetError::Source(format!("S3 list error for prefix '{prefix}': {e}"))
633 })?;
634 keys.push(meta.location);
635 }
636 keys.sort();
637 Ok(keys.into_iter().map(FileTarget::S3).collect())
638}
639
640fn build_s3_store(s3: &ParquetS3Config) -> Result<Arc<dyn ObjectStore>, FaucetError> {
642 if s3.bucket.trim().is_empty() {
643 return Err(FaucetError::Config(
644 "parquet source: S3 bucket must not be empty".into(),
645 ));
646 }
647
648 let mut builder = AmazonS3Builder::from_env().with_bucket_name(&s3.bucket);
649 if let Some(region) = &s3.region {
650 builder = builder.with_region(region);
651 }
652 if let Some(endpoint) = &s3.endpoint_url {
653 builder = builder.with_endpoint(endpoint);
654 if endpoint.starts_with("http://") {
655 builder = builder.with_allow_http(true);
656 }
657 }
658
659 let store = builder
660 .build()
661 .map_err(|e| FaucetError::Config(format!("failed to build S3 client: {e}")))?;
662 Ok(Arc::new(store))
663}
664
665fn validate_projection(
669 requested: &[String],
670 parquet_schema: &parquet::schema::types::SchemaDescriptor,
671 display: &str,
672) -> Result<(), FaucetError> {
673 let root = parquet_schema.root_schema();
674 let parquet::schema::types::Type::GroupType { fields, .. } = root else {
675 return Err(FaucetError::Source(format!(
676 "parquet root schema for '{display}' is not a group"
677 )));
678 };
679
680 let known: std::collections::HashSet<&str> = fields.iter().map(|f| f.name()).collect();
681
682 for name in requested {
683 if !known.contains(name.as_str()) {
684 return Err(FaucetError::Source(format!(
685 "parquet source: projected column '{name}' not found in file '{display}' \
686 (available: {})",
687 known.iter().copied().collect::<Vec<_>>().join(", ")
688 )));
689 }
690 }
691
692 Ok(())
693}
694
695fn schema_mismatch_message(first: &FileOutput, other: &FileOutput) -> String {
697 schema_mismatch_message_pair(
698 &first.path,
699 &first.arrow_schema,
700 &other.path,
701 &other.arrow_schema,
702 )
703}
704
705fn schema_mismatch_message_pair(
709 first_path: &str,
710 first_schema: &arrow::datatypes::SchemaRef,
711 other_path: &str,
712 other_schema: &arrow::datatypes::SchemaRef,
713) -> String {
714 let first_fields: Vec<String> = first_schema
715 .fields()
716 .iter()
717 .map(|f| format!("{}:{}", f.name(), f.data_type()))
718 .collect();
719 let other_fields: Vec<String> = other_schema
720 .fields()
721 .iter()
722 .map(|f| format!("{}:{}", f.name(), f.data_type()))
723 .collect();
724
725 let max_len = first_fields.len().max(other_fields.len());
727 let mut first_diff = None;
728 for i in 0..max_len {
729 let a = first_fields
730 .get(i)
731 .map(String::as_str)
732 .unwrap_or("<missing>");
733 let b = other_fields
734 .get(i)
735 .map(String::as_str)
736 .unwrap_or("<missing>");
737 if a != b {
738 first_diff = Some((i, a.to_string(), b.to_string()));
739 break;
740 }
741 }
742
743 let detail = match first_diff {
744 Some((i, a, b)) => format!(" (field #{i}: '{a}' vs '{b}')"),
745 None => String::new(),
746 };
747
748 format!("parquet source: schema mismatch between '{first_path}' and '{other_path}'{detail}")
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use crate::config::ParquetSourceConfig;
755 use faucet_core::Source;
756
757 #[test]
758 fn substitute_passes_through_when_context_empty() {
759 let ctx = HashMap::new();
760 assert_eq!(substitute("/tmp/{x}.parquet", &ctx), "/tmp/{x}.parquet");
761 }
762
763 #[test]
764 fn substitute_replaces_placeholders() {
765 let mut ctx = HashMap::new();
766 ctx.insert("region".to_string(), Value::String("us".into()));
767 assert_eq!(
768 substitute("data/{region}/x.parquet", &ctx),
769 "data/us/x.parquet"
770 );
771 }
772
773 #[tokio::test]
774 async fn accepts_zero_batch_size_as_sentinel() {
775 let cfg = ParquetSourceConfig::local("/tmp/x.parquet").batch_size(0);
779 let source = ParquetSource::new(cfg)
780 .await
781 .expect("batch_size=0 must be accepted as the no-batching sentinel");
782 assert_eq!(source.config.batch_size, 0);
783 }
784
785 #[tokio::test]
786 async fn rejects_zero_concurrency() {
787 let cfg = ParquetSourceConfig::local("/tmp/x.parquet").concurrency(0);
788 match ParquetSource::new(cfg).await {
789 Err(FaucetError::Config(msg)) => assert!(msg.contains("concurrency")),
790 other => panic!("expected Config error, got {:?}", other.err()),
791 }
792 }
793
794 #[tokio::test]
795 async fn rejects_s3_with_both_key_and_prefix() {
796 let mut s3 = ParquetS3Config::object("b", "k.parquet");
797 s3.prefix = Some("p/".into());
798 let cfg = ParquetSourceConfig::s3(s3);
799 let source = ParquetSource::new(cfg).await.unwrap();
800 let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
801 assert!(matches!(err, FaucetError::Config(_)));
802 }
803
804 #[tokio::test]
805 async fn rejects_s3_with_neither_key_nor_prefix() {
806 let s3 = ParquetS3Config {
807 bucket: "b".into(),
808 key: None,
809 prefix: None,
810 region: None,
811 endpoint_url: None,
812 };
813 let cfg = ParquetSourceConfig::s3(s3);
814 let source = ParquetSource::new(cfg).await.unwrap();
815 let err = source.resolve_files(&HashMap::new()).await.unwrap_err();
816 assert!(matches!(err, FaucetError::Config(_)));
817 }
818
819 #[test]
820 fn empty_bucket_rejected() {
821 let s3 = ParquetS3Config::object("", "k.parquet");
822 let err = build_s3_store(&s3).unwrap_err();
823 assert!(matches!(err, FaucetError::Config(_)));
824 }
825
826 #[tokio::test]
827 async fn dataset_uri_local_path() {
828 let cfg = ParquetSourceConfig::local("/tmp/data.parquet");
829 let source = ParquetSource::new(cfg).await.unwrap();
830 assert_eq!(source.dataset_uri(), "file:///tmp/data.parquet");
831 }
832
833 #[tokio::test]
834 async fn dataset_uri_glob() {
835 let cfg = ParquetSourceConfig::glob("/tmp/data/*.parquet");
836 let source = ParquetSource::new(cfg).await.unwrap();
837 assert_eq!(source.dataset_uri(), "file:///tmp/data/*.parquet");
838 }
839
840 #[tokio::test]
841 async fn dataset_uri_s3_with_key() {
842 let s3 = ParquetS3Config::object("my-bucket", "path/to/file.parquet");
843 let cfg = ParquetSourceConfig::s3(s3);
844 let source = ParquetSource::new(cfg).await.unwrap();
845 assert_eq!(source.dataset_uri(), "s3://my-bucket/path/to/file.parquet");
846 }
847
848 fn glob_fixture(n: usize) -> tempfile::TempDir {
854 let dir = tempfile::tempdir().expect("tempdir");
855 for i in 0..n {
856 std::fs::write(dir.path().join(format!("part-{i:02}.parquet")), b"").expect("touch");
857 }
858 dir
859 }
860
861 #[tokio::test]
864 async fn shards_partition_resolved_files_disjointly_and_completely() {
865 let dir = glob_fixture(12);
866 let pattern = format!("{}/*.parquet", dir.path().display());
867 let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
868 .await
869 .unwrap();
870
871 assert!(source.is_shardable());
872 let shards = source.enumerate_shards(3).await.unwrap();
873 assert_eq!(shards.len(), 3);
874
875 let ctx = HashMap::new();
876 let all: Vec<String> = source
877 .resolve_files(&ctx)
878 .await
879 .unwrap()
880 .iter()
881 .map(FileTarget::display)
882 .collect();
883 assert_eq!(all.len(), 12);
884
885 let mut union: Vec<String> = Vec::new();
886 for shard in &shards {
887 source.apply_shard(shard).await.unwrap();
888 union.extend(
889 source
890 .resolve_files(&ctx)
891 .await
892 .unwrap()
893 .iter()
894 .map(FileTarget::display),
895 );
896 }
897 union.sort();
898 let mut expected = all.clone();
899 expected.sort();
900 assert_eq!(
901 union, expected,
902 "shards must union to the full file set, disjointly"
903 );
904 }
905
906 #[tokio::test]
907 async fn whole_shard_and_target_one_read_everything() {
908 let dir = glob_fixture(4);
909 let pattern = format!("{}/*.parquet", dir.path().display());
910 let source = ParquetSource::new(ParquetSourceConfig::glob(&pattern))
911 .await
912 .unwrap();
913
914 let shards = source.enumerate_shards(1).await.unwrap();
916 assert_eq!(shards.len(), 1);
917 assert!(shards[0].is_whole());
918
919 let ctx = HashMap::new();
920 source.apply_shard(&shards[0]).await.unwrap();
921 assert_eq!(source.resolve_files(&ctx).await.unwrap().len(), 4);
922 }
923
924 #[tokio::test]
925 async fn apply_shard_rejects_malformed_descriptor() {
926 let source = ParquetSource::new(ParquetSourceConfig::local("/tmp/x.parquet"))
927 .await
928 .unwrap();
929 let bad = faucet_core::ShardSpec::new("0", serde_json::json!({ "index": 0 }));
930 assert!(source.apply_shard(&bad).await.is_err());
931 }
932}