1use crate::config::{S3FileFormat, S3SourceConfig};
4use async_trait::async_trait;
5use aws_sdk_s3::Client;
6use faucet_core::shard::{HashShard, ShardSpec, parse_hash_shard, plan_hash_shards};
7use faucet_core::{FaucetError, Stream, StreamPage};
8use futures::stream::{self, StreamExt, TryStreamExt};
9use serde_json::Value;
10use std::pin::Pin;
11use std::sync::Mutex;
12use tokio::io::AsyncBufReadExt;
13
14pub struct S3Source {
16 config: S3SourceConfig,
17 client: Client,
18 applied_shard: Mutex<Option<HashShard>>,
22}
23
24impl S3Source {
25 pub async fn new(config: S3SourceConfig) -> Result<Self, FaucetError> {
29 let client = Self::build_client(&config).await?;
30 Ok(Self {
31 config,
32 client,
33 applied_shard: Mutex::new(None),
34 })
35 }
36
37 fn shard_filter(&self, keys: Vec<String>) -> Vec<String> {
40 match *self.applied_shard.lock().expect("shard mutex poisoned") {
41 Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
42 None => keys,
43 }
44 }
45
46 async fn build_client(config: &S3SourceConfig) -> Result<Client, FaucetError> {
48 let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
49
50 if let Some(ref region) = config.region {
51 config_loader = config_loader.region(aws_config::Region::new(region.clone()));
52 }
53
54 if let Some(ref endpoint) = config.endpoint_url {
55 config_loader = config_loader.endpoint_url(endpoint);
56 }
57
58 let sdk_config = config_loader.load().await;
59 let client = Client::new(&sdk_config);
60 Ok(client)
61 }
62
63 async fn list_object_keys(
68 &self,
69 prefix_override: Option<&str>,
70 ) -> Result<Vec<String>, FaucetError> {
71 let mut keys = Vec::new();
72 let mut continuation_token: Option<String> = None;
73
74 let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
75
76 loop {
77 let mut req = self.client.list_objects_v2().bucket(&self.config.bucket);
78
79 if let Some(prefix) = effective_prefix {
80 req = req.prefix(prefix);
81 }
82
83 if let Some(ref token) = continuation_token {
84 req = req.continuation_token(token);
85 }
86
87 let response = req.send().await.map_err(|e| {
88 FaucetError::Source(format!(
89 "S3 list objects error for bucket '{}': {e}",
90 self.config.bucket
91 ))
92 })?;
93
94 for object in response.contents() {
95 let key: &str = object.key().unwrap_or_default();
96 if key.is_empty() {
97 continue;
98 }
99 keys.push(key.to_string());
100
101 if let Some(max) = self.config.max_objects
102 && keys.len() >= max
103 {
104 return Ok(self.shard_filter(keys));
105 }
106 }
107
108 if response.is_truncated() == Some(true) {
109 continuation_token = response.next_continuation_token().map(String::from);
110 } else {
111 break;
112 }
113 }
114
115 Ok(self.shard_filter(keys))
116 }
117
118 async fn read_object(&self, key: &str) -> Result<Vec<Value>, FaucetError> {
120 #[cfg(feature = "arrow")]
121 if matches!(self.config.file_format, S3FileFormat::Parquet) {
122 let (_schema, batches) = self.read_object_parquet(key).await?;
123 let mut rows = Vec::new();
124 for batch in &batches {
125 rows.extend(faucet_core::columnar::record_batch_to_values(batch)?);
126 }
127 return Ok(rows);
128 }
129 let text = self.read_object_text(key).await?;
130 self.parse_content(key, &text)
131 }
132
133 #[cfg(feature = "arrow")]
140 async fn read_object_bytes(&self, key: &str) -> Result<bytes::Bytes, FaucetError> {
141 use tokio::io::AsyncReadExt as _;
142 let mut reader = self.open_object_reader(key).await?;
143 let mut buf = Vec::new();
144 reader
145 .read_to_end(&mut buf)
146 .await
147 .map_err(|e| FaucetError::Source(format!("S3 read error for key '{key}': {e}")))?;
148 Ok(bytes::Bytes::from(buf))
149 }
150
151 #[cfg(feature = "arrow")]
156 async fn read_object_parquet(
157 &self,
158 key: &str,
159 ) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
160 let data = self.read_object_bytes(key).await?;
161 let key_owned = key.to_string();
162 tokio::task::spawn_blocking(move || decode_parquet_bytes(data, &key_owned))
163 .await
164 .map_err(|e| {
165 FaucetError::Source(format!("parquet decode task for '{key}' panicked: {e}"))
166 })?
167 }
168
169 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
177 use tokio::io::AsyncReadExt as _;
178 let mut reader = self.open_object_reader(key).await?;
179 let mut text = String::new();
180 reader.read_to_string(&mut text).await.map_err(|e| {
181 FaucetError::Source(format!(
182 "S3 read/decode error for key '{key}' (not valid UTF-8?): {e}"
183 ))
184 })?;
185 Ok(text)
186 }
187
188 async fn open_object_reader(
193 &self,
194 key: &str,
195 ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
196 let mut request = self
197 .client
198 .get_object()
199 .bucket(&self.config.bucket)
200 .key(key);
201 if self.config.verify_checksum {
203 request = request.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled);
204 }
205 let response = request.send().await.map_err(|e| {
206 FaucetError::Source(format!("S3 get object error for key '{key}': {e}"))
207 })?;
208
209 let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
213 match crate::verify::length_check(response.content_length(), self.config.verify_length) {
214 Some(check) => checks.push(check),
215 None if self.config.verify_length => tracing::debug!(
216 key = %key,
217 "S3 object reports no Content-Length; length verification skipped"
218 ),
219 None => {}
220 }
221 if self.config.verify_checksum {
222 let advertised = crate::verify::S3Checksums {
223 crc32: response.checksum_crc32().map(str::to_string),
224 crc32c: response.checksum_crc32_c().map(str::to_string),
225 crc64nvme: response.checksum_crc64_nvme().map(str::to_string),
226 sha256: response.checksum_sha256().map(str::to_string),
227 etag: response.e_tag().map(str::to_string),
228 };
229 match crate::verify::checksum_check(&advertised) {
230 Some(check) => checks.push(check),
231 None => tracing::warn!(
232 key = %key,
233 "verify_checksum is enabled but S3 advertised no verifiable checksum for \
234 this object; relying on the length check only"
235 ),
236 }
237 }
238
239 let verified = faucet_core::VerifyingReader::new(response.body.into_async_read(), checks);
244 let buffered = tokio::io::BufReader::new(verified);
245 #[cfg(feature = "compression")]
246 {
247 let codec = self.config.compression.resolve(key);
248 faucet_core::compression::warn_mismatch(key, codec);
249 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
250 }
251 #[cfg(not(feature = "compression"))]
252 {
253 Ok(Box::pin(buffered))
254 }
255 }
256
257 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
259 match self.config.file_format {
260 S3FileFormat::JsonLines => {
261 let mut records = Vec::new();
262 for (line_num, line) in text.lines().enumerate() {
263 let trimmed = line.trim();
264 if trimmed.is_empty() {
265 continue;
266 }
267 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
268 FaucetError::Source(format!(
269 "S3 JSON parse error in '{key}' at line {}: {e}",
270 line_num + 1
271 ))
272 })?;
273 records.push(value);
274 }
275 Ok(records)
276 }
277 S3FileFormat::JsonArray => {
278 let value: Value = serde_json::from_str(text).map_err(|e| {
279 FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
280 })?;
281 match value {
282 Value::Array(arr) => Ok(arr),
283 _ => Err(FaucetError::Source(format!(
284 "S3 expected JSON array in '{key}', got {}",
285 value_type_name(&value)
286 ))),
287 }
288 }
289 S3FileFormat::RawText => {
290 let record = serde_json::json!({
291 "key": key,
292 "content": text,
293 });
294 Ok(vec![record])
295 }
296 #[cfg(feature = "arrow")]
300 S3FileFormat::Parquet => Err(FaucetError::Source(format!(
301 "S3 parquet object '{key}' cannot be parsed as text (internal error: \
302 parquet must use the binary decode path)"
303 ))),
304 }
305 }
306}
307
308#[async_trait]
309impl faucet_core::Source for S3Source {
310 async fn fetch_with_context(
311 &self,
312 context: &std::collections::HashMap<String, serde_json::Value>,
313 ) -> Result<Vec<Value>, FaucetError> {
314 let substituted_prefix: Option<String> = if !context.is_empty() {
316 self.config
317 .prefix
318 .as_ref()
319 .map(|p| faucet_core::util::substitute_context(p, context))
320 } else {
321 None
322 };
323
324 let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
325
326 tracing::info!(
327 bucket = %self.config.bucket,
328 objects = keys.len(),
329 "Listed S3 objects"
330 );
331
332 let concurrency = self.config.concurrency.max(1);
333
334 let results: Vec<Vec<Value>> = stream::iter(keys)
335 .map(|key| async move {
336 let records = self.read_object(&key).await?;
337 tracing::debug!(key = %key, records = records.len(), "Read S3 object");
338 Ok::<Vec<Value>, FaucetError>(records)
339 })
340 .buffer_unordered(concurrency)
341 .try_collect()
342 .await?;
343
344 let all_records: Vec<Value> = results.into_iter().flatten().collect();
345
346 tracing::info!(total_records = all_records.len(), "S3 fetch complete");
347 Ok(all_records)
348 }
349
350 fn stream_pages<'a>(
376 &'a self,
377 context: &'a std::collections::HashMap<String, Value>,
378 _batch_size: usize,
379 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
380 let batch_size = self.config.batch_size;
381
382 Box::pin(async_stream::try_stream! {
383 let substituted_prefix: Option<String> = if !context.is_empty() {
385 self.config
386 .prefix
387 .as_ref()
388 .map(|p| faucet_core::util::substitute_context(p, context))
389 } else {
390 None
391 };
392
393 let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
394 tracing::info!(
395 bucket = %self.config.bucket,
396 objects = keys.len(),
397 "Listed S3 objects (stream)",
398 );
399
400 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
401 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
402 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
403 let mut total = 0usize;
404
405 for key in &keys {
406 match self.config.file_format {
407 S3FileFormat::JsonLines => {
408 let reader = self.open_object_reader(key).await?;
409 let mut lines = reader.lines();
410 let mut line_num: usize = 0;
411 while let Some(line) = lines
412 .next_line()
413 .await
414 .map_err(|e| FaucetError::Source(format!(
415 "S3 read body error for key '{key}': {e}"
416 )))?
417 {
418 line_num += 1;
419 let trimmed = line.trim();
420 if trimmed.is_empty() {
421 continue;
422 }
423 let value: Value =
424 serde_json::from_str(trimmed).map_err(|e| {
425 FaucetError::Source(format!(
426 "S3 JSON parse error in '{key}' at line {line_num}: {e}",
427 ))
428 })?;
429 buffer.push(value);
430 if batch_size != 0 && buffer.len() >= chunk {
431 let page = std::mem::replace(
432 &mut buffer,
433 Vec::with_capacity(initial_capacity),
434 );
435 total += page.len();
436 yield StreamPage { records: page, bookmark: None };
437 }
438 }
439 if batch_size == 0 && !buffer.is_empty() {
440 let page = std::mem::take(&mut buffer);
441 total += page.len();
442 yield StreamPage { records: page, bookmark: None };
443 }
444 }
445 S3FileFormat::RawText => {
446 let text = self.read_object_text(key).await?;
451 let record = serde_json::json!({
452 "key": key,
453 "content": text,
454 });
455 buffer.push(record);
456 if batch_size == 0 {
457 let page = std::mem::take(&mut buffer);
458 total += page.len();
459 yield StreamPage { records: page, bookmark: None };
460 } else if buffer.len() >= chunk {
461 let page = std::mem::replace(
462 &mut buffer,
463 Vec::with_capacity(initial_capacity),
464 );
465 total += page.len();
466 yield StreamPage { records: page, bookmark: None };
467 }
468 }
469 #[cfg(feature = "arrow")]
470 S3FileFormat::Parquet => {
471 let (_schema, batches) = self.read_object_parquet(key).await?;
477 for batch in &batches {
478 let rows = faucet_core::columnar::record_batch_to_values(batch)?;
479 for record in rows {
480 buffer.push(record);
481 if batch_size != 0 && buffer.len() >= chunk {
482 let page = std::mem::replace(
483 &mut buffer,
484 Vec::with_capacity(initial_capacity),
485 );
486 total += page.len();
487 yield StreamPage { records: page, bookmark: None };
488 }
489 }
490 }
491 if batch_size == 0 && !buffer.is_empty() {
492 let page = std::mem::take(&mut buffer);
493 total += page.len();
494 yield StreamPage { records: page, bookmark: None };
495 }
496 }
497 S3FileFormat::JsonArray => {
498 let text = self.read_object_text(key).await?;
504 let value: Value = serde_json::from_str(&text).map_err(|e| {
505 FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
506 })?;
507 let array = match value {
508 Value::Array(arr) => arr,
509 other => Err(FaucetError::Source(format!(
510 "S3 expected JSON array in '{key}', got {}",
511 value_type_name(&other)
512 )))?,
513 };
514 if batch_size == 0 {
515 if !buffer.is_empty() {
520 let page = std::mem::take(&mut buffer);
521 total += page.len();
522 yield StreamPage { records: page, bookmark: None };
523 }
524 total += array.len();
525 yield StreamPage { records: array, bookmark: None };
526 } else {
527 for record in array {
528 buffer.push(record);
529 if buffer.len() >= chunk {
530 let page = std::mem::replace(
531 &mut buffer,
532 Vec::with_capacity(initial_capacity),
533 );
534 total += page.len();
535 yield StreamPage { records: page, bookmark: None };
536 }
537 }
538 }
539 }
540 }
541 }
542
543 if !buffer.is_empty() {
544 let page = std::mem::take(&mut buffer);
545 total += page.len();
546 yield StreamPage { records: page, bookmark: None };
547 }
548
549 tracing::info!(
550 total_records = total,
551 batch_size,
552 objects = keys.len(),
553 "S3 source stream complete",
554 );
555 })
556 }
557
558 #[cfg(feature = "arrow")]
563 fn supports_columnar(&self) -> bool {
564 matches!(self.config.file_format, S3FileFormat::Parquet)
565 }
566
567 #[cfg(feature = "arrow")]
581 fn stream_batches<'a>(
582 &'a self,
583 context: &'a std::collections::HashMap<String, Value>,
584 _batch_size: usize,
585 ) -> Pin<
586 Box<
587 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
588 >,
589 > {
590 Box::pin(async_stream::try_stream! {
591 if !matches!(self.config.file_format, S3FileFormat::Parquet) {
592 Err(FaucetError::Source(
593 "S3 source: stream_batches invoked for a non-parquet file_format".into(),
594 ))?;
595 }
596
597 let substituted_prefix: Option<String> = if !context.is_empty() {
598 self.config
599 .prefix
600 .as_ref()
601 .map(|p| faucet_core::util::substitute_context(p, context))
602 } else {
603 None
604 };
605
606 let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
607 tracing::info!(
608 bucket = %self.config.bucket,
609 objects = keys.len(),
610 "Listed S3 objects (columnar stream)",
611 );
612
613 let mut reference: Option<arrow::datatypes::SchemaRef> = None;
614 let mut total_records = 0usize;
615 let mut total_pages = 0usize;
616 for key in &keys {
617 let (schema, batches) = self.read_object_parquet(key).await?;
618 match &reference {
619 Some(first) if first != &schema => {
620 Err(FaucetError::Source(format!(
621 "S3 source: parquet schema mismatch — object '{key}' diverges from \
622 the first object's schema"
623 )))?;
624 }
625 None => reference = Some(schema),
626 _ => {}
627 }
628 for batch in batches {
629 if batch.num_rows() == 0 {
630 continue;
631 }
632 total_records += batch.num_rows();
633 total_pages += 1;
634 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
635 }
636 }
637
638 tracing::info!(
639 pages = total_pages,
640 total_records,
641 objects = keys.len(),
642 "S3 source columnar stream complete",
643 );
644 })
645 }
646
647 fn connector_name(&self) -> &'static str {
648 "s3"
649 }
650
651 fn config_schema(&self) -> serde_json::Value {
652 serde_json::to_value(faucet_core::schema_for!(S3SourceConfig))
653 .expect("schema serialization")
654 }
655
656 fn dataset_uri(&self) -> String {
657 match &self.config.prefix {
658 Some(p) => format!("s3://{}/{}", self.config.bucket, p),
659 None => format!("s3://{}", self.config.bucket),
660 }
661 }
662
663 fn is_shardable(&self) -> bool {
668 true
669 }
670
671 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
676 Ok(plan_hash_shards(target))
677 }
678
679 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
682 *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "s3")?;
683 Ok(())
684 }
685
686 fn supports_discover(&self) -> bool {
687 true
688 }
689
690 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
699 let mut req = self
700 .client
701 .list_objects_v2()
702 .bucket(&self.config.bucket)
703 .delimiter("/")
704 .max_keys(DISCOVER_MAX_OBJECTS as i32);
705 if let Some(prefix) = self.config.prefix.as_deref() {
706 req = req.prefix(prefix);
707 }
708 let response = req
709 .send()
710 .await
711 .map_err(|e| FaucetError::Source(format!("s3: catalog discovery failed: {e}")))?;
712
713 let prefixes: Vec<String> = response
714 .common_prefixes()
715 .iter()
716 .filter_map(|p| p.prefix())
717 .filter(|p| !p.is_empty())
718 .map(str::to_string)
719 .collect();
720 let objects: Vec<String> = response
721 .contents()
722 .iter()
723 .filter_map(|o| o.key())
724 .filter(|k| !k.is_empty())
725 .map(str::to_string)
726 .collect();
727
728 Ok(descriptors_from_listing(prefixes, objects))
729 }
730}
731
732const DISCOVER_MAX_OBJECTS: usize = 1000;
735
736fn descriptors_from_listing(
743 prefixes: Vec<String>,
744 objects: Vec<String>,
745) -> Vec<faucet_core::DatasetDescriptor> {
746 if !prefixes.is_empty() {
747 return prefixes
748 .into_iter()
749 .map(|p| {
750 let patch = serde_json::json!({ "prefix": p });
751 faucet_core::DatasetDescriptor::new(p, "prefix", patch)
752 })
753 .collect();
754 }
755 objects
756 .into_iter()
757 .take(DISCOVER_MAX_OBJECTS)
758 .map(|k| {
759 let patch = serde_json::json!({ "prefix": k });
760 faucet_core::DatasetDescriptor::new(k, "object", patch)
761 })
762 .collect()
763}
764
765#[cfg(feature = "arrow")]
772fn decode_parquet_bytes(
773 data: bytes::Bytes,
774 key: &str,
775) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
776 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
777
778 let builder = ParquetRecordBatchReaderBuilder::try_new(data).map_err(|e| {
779 FaucetError::Source(format!("failed to read parquet metadata for '{key}': {e}"))
780 })?;
781 let schema = builder.schema().clone();
782 let reader = builder.build().map_err(|e| {
783 FaucetError::Source(format!("failed to build parquet reader for '{key}': {e}"))
784 })?;
785
786 let mut batches = Vec::new();
787 for batch in reader {
788 batches.push(
789 batch.map_err(|e| {
790 FaucetError::Source(format!("parquet decode error in '{key}': {e}"))
791 })?,
792 );
793 }
794 Ok((schema, batches))
795}
796
797fn value_type_name(v: &Value) -> &'static str {
799 match v {
800 Value::Null => "null",
801 Value::Bool(_) => "boolean",
802 Value::Number(_) => "number",
803 Value::String(_) => "string",
804 Value::Array(_) => "array",
805 Value::Object(_) => "object",
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use crate::config::S3SourceConfig;
813 use faucet_core::Source;
814 use serde_json::json;
815
816 fn test_source(config: S3SourceConfig) -> S3Source {
820 let sdk_config = aws_config::SdkConfig::builder()
822 .behavior_version(aws_config::BehaviorVersion::latest())
823 .build();
824 let client = Client::new(&sdk_config);
825 S3Source {
826 config,
827 client,
828 applied_shard: Mutex::new(None),
829 }
830 }
831
832 #[test]
833 fn parse_json_lines() {
834 let source = test_source(S3SourceConfig::new("test"));
835 let text = r#"{"id":1,"name":"Alice"}
836{"id":2,"name":"Bob"}
837"#;
838 let records = source.parse_content("test.jsonl", text).unwrap();
839 assert_eq!(records.len(), 2);
840 assert_eq!(records[0]["id"], 1);
841 assert_eq!(records[1]["name"], "Bob");
842 }
843
844 #[test]
845 fn parse_json_lines_skips_empty() {
846 let source = test_source(S3SourceConfig::new("test"));
847 let text = r#"{"id":1}
848
849{"id":2}
850
851"#;
852 let records = source.parse_content("test.jsonl", text).unwrap();
853 assert_eq!(records.len(), 2);
854 }
855
856 #[test]
857 fn parse_json_lines_invalid() {
858 let source = test_source(S3SourceConfig::new("test"));
859 let text = "not json\n";
860 let result = source.parse_content("test.jsonl", text);
861 assert!(result.is_err());
862 let err = result.unwrap_err().to_string();
863 assert!(err.contains("JSON parse error"));
864 assert!(err.contains("line 1"));
865 }
866
867 #[test]
868 fn parse_json_array() {
869 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
870 let text = r#"[{"id":1},{"id":2}]"#;
871 let records = source.parse_content("test.json", text).unwrap();
872 assert_eq!(records.len(), 2);
873 assert_eq!(records[0]["id"], 1);
874 }
875
876 #[test]
877 fn parse_json_array_not_array() {
878 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
879 let text = r#"{"id":1}"#;
880 let result = source.parse_content("test.json", text);
881 assert!(result.is_err());
882 let err = result.unwrap_err().to_string();
883 assert!(err.contains("expected JSON array"));
884 }
885
886 #[test]
887 fn parse_raw_text() {
888 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::RawText));
889 let text = "hello world\nline two";
890 let records = source.parse_content("data/file.txt", text).unwrap();
891 assert_eq!(records.len(), 1);
892 assert_eq!(
893 records[0],
894 json!({"key": "data/file.txt", "content": "hello world\nline two"})
895 );
896 }
897
898 #[cfg(feature = "compression")]
899 #[test]
900 fn compression_default_is_auto() {
901 let cfg = S3SourceConfig::new("bucket");
902 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
903 }
904
905 #[test]
908 fn shard_hash_is_deterministic() {
909 use faucet_core::shard::shard_hash;
910 assert_eq!(
911 shard_hash("data/part-001.jsonl"),
912 shard_hash("data/part-001.jsonl")
913 );
914 assert_ne!(shard_hash("a"), shard_hash("b"));
915 }
916
917 #[tokio::test]
918 async fn enumerate_shards_returns_target_disjoint_shards() {
919 let source = test_source(S3SourceConfig::new("b"));
920 assert!(source.is_shardable());
921 let shards = source.enumerate_shards(3).await.unwrap();
922 assert_eq!(shards.len(), 3);
923 for (i, s) in shards.iter().enumerate() {
924 assert_eq!(s.descriptor["shards"], 3);
925 assert_eq!(s.descriptor["index"], i);
926 }
927 }
928
929 #[tokio::test]
930 async fn enumerate_shards_target_one_is_whole() {
931 let source = test_source(S3SourceConfig::new("b"));
932 let shards = source.enumerate_shards(1).await.unwrap();
933 assert_eq!(shards.len(), 1);
934 assert!(shards[0].is_whole());
935 }
936
937 #[tokio::test]
940 async fn shard_filter_partitions_keys_disjointly_and_completely() {
941 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
942 let n = 4;
943 let mut union: Vec<String> = Vec::new();
944 for index in 0..n {
945 let source = test_source(S3SourceConfig::new("b"));
946 source
947 .apply_shard(&ShardSpec::new(
948 index.to_string(),
949 serde_json::json!({ "shards": n, "index": index }),
950 ))
951 .await
952 .unwrap();
953 let got = source.shard_filter(keys.clone());
954 union.extend(got);
955 }
956 union.sort();
957 let mut expected = keys.clone();
958 expected.sort();
959 assert_eq!(
960 union, expected,
961 "shards must union to the full key set, disjointly"
962 );
963 }
964
965 #[tokio::test]
966 async fn apply_whole_shard_reads_everything() {
967 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
968 let source = test_source(S3SourceConfig::new("b"));
969 source.apply_shard(&ShardSpec::whole()).await.unwrap();
970 assert_eq!(source.shard_filter(keys.clone()).len(), keys.len());
971 }
972
973 #[tokio::test]
974 async fn apply_shard_rejects_malformed_descriptor() {
975 let source = test_source(S3SourceConfig::new("b"));
976 let err = source
977 .apply_shard(&ShardSpec::new("0", serde_json::json!({ "index": 0 })))
978 .await
979 .unwrap_err();
980 assert!(matches!(err, FaucetError::Source(_)));
981 }
982
983 #[test]
986 fn descriptors_from_listing_maps_common_prefixes() {
987 let out = descriptors_from_listing(
988 vec!["raw/orders/".to_string(), "raw/users/".to_string()],
989 vec![],
990 );
991 assert_eq!(out.len(), 2);
992 assert_eq!(out[0].name, "raw/orders/");
993 assert_eq!(out[0].kind, "prefix");
994 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
995 assert!(out[0].schema.is_none());
996 assert!(out[0].estimated_rows.is_none());
997 assert_eq!(out[1].name, "raw/users/");
998 assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
999 }
1000
1001 #[test]
1004 fn descriptors_from_listing_prefers_prefixes_over_objects() {
1005 let out = descriptors_from_listing(
1006 vec!["raw/orders/".to_string()],
1007 vec!["raw/readme.txt".to_string()],
1008 );
1009 assert_eq!(out.len(), 1);
1010 assert_eq!(out[0].kind, "prefix");
1011 assert_eq!(out[0].name, "raw/orders/");
1012 }
1013
1014 #[test]
1015 fn descriptors_from_listing_falls_back_to_objects() {
1016 let out = descriptors_from_listing(
1017 vec![],
1018 vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
1019 );
1020 assert_eq!(out.len(), 2);
1021 assert_eq!(out[0].name, "raw/a.jsonl");
1022 assert_eq!(out[0].kind, "object");
1023 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/a.jsonl" }));
1024 assert!(out[0].schema.is_none());
1025 assert!(out[0].estimated_rows.is_none());
1026 }
1027
1028 #[test]
1029 fn descriptors_from_listing_empty_listing_yields_no_datasets() {
1030 assert!(descriptors_from_listing(vec![], vec![]).is_empty());
1031 }
1032
1033 #[test]
1034 fn descriptors_from_listing_caps_object_fallback() {
1035 let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
1036 .map(|i| format!("obj-{i}.jsonl"))
1037 .collect();
1038 let out = descriptors_from_listing(vec![], objects);
1039 assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
1040 }
1041
1042 #[test]
1043 fn source_advertises_discover() {
1044 let source = test_source(S3SourceConfig::new("my-bucket"));
1045 assert!(source.supports_discover());
1046 }
1047
1048 #[test]
1049 fn dataset_uri_no_prefix() {
1050 let source = test_source(S3SourceConfig::new("my-bucket"));
1051 assert_eq!(source.dataset_uri(), "s3://my-bucket");
1052 }
1053
1054 #[test]
1055 fn dataset_uri_with_prefix() {
1056 let source = test_source(S3SourceConfig::new("my-bucket").prefix("data/2026/"));
1057 assert_eq!(source.dataset_uri(), "s3://my-bucket/data/2026/");
1058 }
1059
1060 #[cfg(feature = "arrow")]
1063 fn sample_parquet_bytes() -> bytes::Bytes {
1064 use arrow::array::{Int32Array, RecordBatch, StringArray};
1065 use arrow::datatypes::{DataType, Field, Schema};
1066 use std::sync::Arc;
1067
1068 let schema = Arc::new(Schema::new(vec![
1069 Field::new("id", DataType::Int32, false),
1070 Field::new("name", DataType::Utf8, true),
1071 ]));
1072 let batch = RecordBatch::try_new(
1073 schema.clone(),
1074 vec![
1075 Arc::new(Int32Array::from(vec![1, 2])),
1076 Arc::new(StringArray::from(vec![Some("Alice"), None])),
1077 ],
1078 )
1079 .unwrap();
1080 let mut buf: Vec<u8> = Vec::new();
1081 {
1082 let mut writer = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
1083 writer.write(&batch).unwrap();
1084 writer.close().unwrap();
1085 }
1086 bytes::Bytes::from(buf)
1087 }
1088
1089 #[cfg(feature = "arrow")]
1090 #[test]
1091 fn decode_parquet_bytes_yields_schema_and_batches() {
1092 let (schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1093 assert_eq!(schema.fields().len(), 2);
1094 assert_eq!(schema.field(0).name(), "id");
1095 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1096 assert_eq!(total, 2);
1097 }
1098
1099 #[cfg(feature = "arrow")]
1100 #[test]
1101 fn parquet_batches_convert_to_rows() {
1102 let (_schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1103 let mut rows = Vec::new();
1104 for b in &batches {
1105 rows.extend(faucet_core::columnar::record_batch_to_values(b).unwrap());
1106 }
1107 assert_eq!(rows.len(), 2);
1108 assert_eq!(rows[0]["id"], 1);
1109 assert_eq!(rows[0]["name"], "Alice");
1110 assert!(rows[1].as_object().unwrap().contains_key("name"));
1112 assert!(rows[1]["name"].is_null());
1113 }
1114
1115 #[cfg(feature = "arrow")]
1116 #[test]
1117 fn corrupt_parquet_bytes_error() {
1118 let err = decode_parquet_bytes(bytes::Bytes::from_static(b"not parquet"), "bad.parquet")
1119 .unwrap_err();
1120 assert!(matches!(err, FaucetError::Source(_)));
1121 }
1122
1123 #[cfg(feature = "arrow")]
1124 #[test]
1125 fn supports_columnar_only_for_parquet_format() {
1126 let parquet_src = test_source(S3SourceConfig::new("b").file_format(S3FileFormat::Parquet));
1127 assert!(faucet_core::Source::supports_columnar(&parquet_src));
1128
1129 let json_src = test_source(S3SourceConfig::new("b"));
1130 assert!(!faucet_core::Source::supports_columnar(&json_src));
1131 }
1132}