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 config_schema(&self) -> serde_json::Value {
648 serde_json::to_value(faucet_core::schema_for!(S3SourceConfig))
649 .expect("schema serialization")
650 }
651
652 fn dataset_uri(&self) -> String {
653 match &self.config.prefix {
654 Some(p) => format!("s3://{}/{}", self.config.bucket, p),
655 None => format!("s3://{}", self.config.bucket),
656 }
657 }
658
659 fn is_shardable(&self) -> bool {
664 true
665 }
666
667 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
672 Ok(plan_hash_shards(target))
673 }
674
675 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
678 *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "s3")?;
679 Ok(())
680 }
681
682 fn supports_discover(&self) -> bool {
683 true
684 }
685
686 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
695 let mut req = self
696 .client
697 .list_objects_v2()
698 .bucket(&self.config.bucket)
699 .delimiter("/")
700 .max_keys(DISCOVER_MAX_OBJECTS as i32);
701 if let Some(prefix) = self.config.prefix.as_deref() {
702 req = req.prefix(prefix);
703 }
704 let response = req
705 .send()
706 .await
707 .map_err(|e| FaucetError::Source(format!("s3: catalog discovery failed: {e}")))?;
708
709 let prefixes: Vec<String> = response
710 .common_prefixes()
711 .iter()
712 .filter_map(|p| p.prefix())
713 .filter(|p| !p.is_empty())
714 .map(str::to_string)
715 .collect();
716 let objects: Vec<String> = response
717 .contents()
718 .iter()
719 .filter_map(|o| o.key())
720 .filter(|k| !k.is_empty())
721 .map(str::to_string)
722 .collect();
723
724 Ok(descriptors_from_listing(prefixes, objects))
725 }
726}
727
728const DISCOVER_MAX_OBJECTS: usize = 1000;
731
732fn descriptors_from_listing(
739 prefixes: Vec<String>,
740 objects: Vec<String>,
741) -> Vec<faucet_core::DatasetDescriptor> {
742 if !prefixes.is_empty() {
743 return prefixes
744 .into_iter()
745 .map(|p| {
746 let patch = serde_json::json!({ "prefix": p });
747 faucet_core::DatasetDescriptor::new(p, "prefix", patch)
748 })
749 .collect();
750 }
751 objects
752 .into_iter()
753 .take(DISCOVER_MAX_OBJECTS)
754 .map(|k| {
755 let patch = serde_json::json!({ "prefix": k });
756 faucet_core::DatasetDescriptor::new(k, "object", patch)
757 })
758 .collect()
759}
760
761#[cfg(feature = "arrow")]
768fn decode_parquet_bytes(
769 data: bytes::Bytes,
770 key: &str,
771) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
772 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
773
774 let builder = ParquetRecordBatchReaderBuilder::try_new(data).map_err(|e| {
775 FaucetError::Source(format!("failed to read parquet metadata for '{key}': {e}"))
776 })?;
777 let schema = builder.schema().clone();
778 let reader = builder.build().map_err(|e| {
779 FaucetError::Source(format!("failed to build parquet reader for '{key}': {e}"))
780 })?;
781
782 let mut batches = Vec::new();
783 for batch in reader {
784 batches.push(
785 batch.map_err(|e| {
786 FaucetError::Source(format!("parquet decode error in '{key}': {e}"))
787 })?,
788 );
789 }
790 Ok((schema, batches))
791}
792
793fn value_type_name(v: &Value) -> &'static str {
795 match v {
796 Value::Null => "null",
797 Value::Bool(_) => "boolean",
798 Value::Number(_) => "number",
799 Value::String(_) => "string",
800 Value::Array(_) => "array",
801 Value::Object(_) => "object",
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::config::S3SourceConfig;
809 use faucet_core::Source;
810 use serde_json::json;
811
812 fn test_source(config: S3SourceConfig) -> S3Source {
816 let sdk_config = aws_config::SdkConfig::builder()
818 .behavior_version(aws_config::BehaviorVersion::latest())
819 .build();
820 let client = Client::new(&sdk_config);
821 S3Source {
822 config,
823 client,
824 applied_shard: Mutex::new(None),
825 }
826 }
827
828 #[test]
829 fn parse_json_lines() {
830 let source = test_source(S3SourceConfig::new("test"));
831 let text = r#"{"id":1,"name":"Alice"}
832{"id":2,"name":"Bob"}
833"#;
834 let records = source.parse_content("test.jsonl", text).unwrap();
835 assert_eq!(records.len(), 2);
836 assert_eq!(records[0]["id"], 1);
837 assert_eq!(records[1]["name"], "Bob");
838 }
839
840 #[test]
841 fn parse_json_lines_skips_empty() {
842 let source = test_source(S3SourceConfig::new("test"));
843 let text = r#"{"id":1}
844
845{"id":2}
846
847"#;
848 let records = source.parse_content("test.jsonl", text).unwrap();
849 assert_eq!(records.len(), 2);
850 }
851
852 #[test]
853 fn parse_json_lines_invalid() {
854 let source = test_source(S3SourceConfig::new("test"));
855 let text = "not json\n";
856 let result = source.parse_content("test.jsonl", text);
857 assert!(result.is_err());
858 let err = result.unwrap_err().to_string();
859 assert!(err.contains("JSON parse error"));
860 assert!(err.contains("line 1"));
861 }
862
863 #[test]
864 fn parse_json_array() {
865 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
866 let text = r#"[{"id":1},{"id":2}]"#;
867 let records = source.parse_content("test.json", text).unwrap();
868 assert_eq!(records.len(), 2);
869 assert_eq!(records[0]["id"], 1);
870 }
871
872 #[test]
873 fn parse_json_array_not_array() {
874 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
875 let text = r#"{"id":1}"#;
876 let result = source.parse_content("test.json", text);
877 assert!(result.is_err());
878 let err = result.unwrap_err().to_string();
879 assert!(err.contains("expected JSON array"));
880 }
881
882 #[test]
883 fn parse_raw_text() {
884 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::RawText));
885 let text = "hello world\nline two";
886 let records = source.parse_content("data/file.txt", text).unwrap();
887 assert_eq!(records.len(), 1);
888 assert_eq!(
889 records[0],
890 json!({"key": "data/file.txt", "content": "hello world\nline two"})
891 );
892 }
893
894 #[cfg(feature = "compression")]
895 #[test]
896 fn compression_default_is_auto() {
897 let cfg = S3SourceConfig::new("bucket");
898 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
899 }
900
901 #[test]
904 fn shard_hash_is_deterministic() {
905 use faucet_core::shard::shard_hash;
906 assert_eq!(
907 shard_hash("data/part-001.jsonl"),
908 shard_hash("data/part-001.jsonl")
909 );
910 assert_ne!(shard_hash("a"), shard_hash("b"));
911 }
912
913 #[tokio::test]
914 async fn enumerate_shards_returns_target_disjoint_shards() {
915 let source = test_source(S3SourceConfig::new("b"));
916 assert!(source.is_shardable());
917 let shards = source.enumerate_shards(3).await.unwrap();
918 assert_eq!(shards.len(), 3);
919 for (i, s) in shards.iter().enumerate() {
920 assert_eq!(s.descriptor["shards"], 3);
921 assert_eq!(s.descriptor["index"], i);
922 }
923 }
924
925 #[tokio::test]
926 async fn enumerate_shards_target_one_is_whole() {
927 let source = test_source(S3SourceConfig::new("b"));
928 let shards = source.enumerate_shards(1).await.unwrap();
929 assert_eq!(shards.len(), 1);
930 assert!(shards[0].is_whole());
931 }
932
933 #[tokio::test]
936 async fn shard_filter_partitions_keys_disjointly_and_completely() {
937 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
938 let n = 4;
939 let mut union: Vec<String> = Vec::new();
940 for index in 0..n {
941 let source = test_source(S3SourceConfig::new("b"));
942 source
943 .apply_shard(&ShardSpec::new(
944 index.to_string(),
945 serde_json::json!({ "shards": n, "index": index }),
946 ))
947 .await
948 .unwrap();
949 let got = source.shard_filter(keys.clone());
950 union.extend(got);
951 }
952 union.sort();
953 let mut expected = keys.clone();
954 expected.sort();
955 assert_eq!(
956 union, expected,
957 "shards must union to the full key set, disjointly"
958 );
959 }
960
961 #[tokio::test]
962 async fn apply_whole_shard_reads_everything() {
963 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
964 let source = test_source(S3SourceConfig::new("b"));
965 source.apply_shard(&ShardSpec::whole()).await.unwrap();
966 assert_eq!(source.shard_filter(keys.clone()).len(), keys.len());
967 }
968
969 #[tokio::test]
970 async fn apply_shard_rejects_malformed_descriptor() {
971 let source = test_source(S3SourceConfig::new("b"));
972 let err = source
973 .apply_shard(&ShardSpec::new("0", serde_json::json!({ "index": 0 })))
974 .await
975 .unwrap_err();
976 assert!(matches!(err, FaucetError::Source(_)));
977 }
978
979 #[test]
982 fn descriptors_from_listing_maps_common_prefixes() {
983 let out = descriptors_from_listing(
984 vec!["raw/orders/".to_string(), "raw/users/".to_string()],
985 vec![],
986 );
987 assert_eq!(out.len(), 2);
988 assert_eq!(out[0].name, "raw/orders/");
989 assert_eq!(out[0].kind, "prefix");
990 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
991 assert!(out[0].schema.is_none());
992 assert!(out[0].estimated_rows.is_none());
993 assert_eq!(out[1].name, "raw/users/");
994 assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
995 }
996
997 #[test]
1000 fn descriptors_from_listing_prefers_prefixes_over_objects() {
1001 let out = descriptors_from_listing(
1002 vec!["raw/orders/".to_string()],
1003 vec!["raw/readme.txt".to_string()],
1004 );
1005 assert_eq!(out.len(), 1);
1006 assert_eq!(out[0].kind, "prefix");
1007 assert_eq!(out[0].name, "raw/orders/");
1008 }
1009
1010 #[test]
1011 fn descriptors_from_listing_falls_back_to_objects() {
1012 let out = descriptors_from_listing(
1013 vec![],
1014 vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
1015 );
1016 assert_eq!(out.len(), 2);
1017 assert_eq!(out[0].name, "raw/a.jsonl");
1018 assert_eq!(out[0].kind, "object");
1019 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/a.jsonl" }));
1020 assert!(out[0].schema.is_none());
1021 assert!(out[0].estimated_rows.is_none());
1022 }
1023
1024 #[test]
1025 fn descriptors_from_listing_empty_listing_yields_no_datasets() {
1026 assert!(descriptors_from_listing(vec![], vec![]).is_empty());
1027 }
1028
1029 #[test]
1030 fn descriptors_from_listing_caps_object_fallback() {
1031 let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
1032 .map(|i| format!("obj-{i}.jsonl"))
1033 .collect();
1034 let out = descriptors_from_listing(vec![], objects);
1035 assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
1036 }
1037
1038 #[test]
1039 fn source_advertises_discover() {
1040 let source = test_source(S3SourceConfig::new("my-bucket"));
1041 assert!(source.supports_discover());
1042 }
1043
1044 #[test]
1045 fn dataset_uri_no_prefix() {
1046 let source = test_source(S3SourceConfig::new("my-bucket"));
1047 assert_eq!(source.dataset_uri(), "s3://my-bucket");
1048 }
1049
1050 #[test]
1051 fn dataset_uri_with_prefix() {
1052 let source = test_source(S3SourceConfig::new("my-bucket").prefix("data/2026/"));
1053 assert_eq!(source.dataset_uri(), "s3://my-bucket/data/2026/");
1054 }
1055
1056 #[cfg(feature = "arrow")]
1059 fn sample_parquet_bytes() -> bytes::Bytes {
1060 use arrow::array::{Int32Array, RecordBatch, StringArray};
1061 use arrow::datatypes::{DataType, Field, Schema};
1062 use std::sync::Arc;
1063
1064 let schema = Arc::new(Schema::new(vec![
1065 Field::new("id", DataType::Int32, false),
1066 Field::new("name", DataType::Utf8, true),
1067 ]));
1068 let batch = RecordBatch::try_new(
1069 schema.clone(),
1070 vec![
1071 Arc::new(Int32Array::from(vec![1, 2])),
1072 Arc::new(StringArray::from(vec![Some("Alice"), None])),
1073 ],
1074 )
1075 .unwrap();
1076 let mut buf: Vec<u8> = Vec::new();
1077 {
1078 let mut writer = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
1079 writer.write(&batch).unwrap();
1080 writer.close().unwrap();
1081 }
1082 bytes::Bytes::from(buf)
1083 }
1084
1085 #[cfg(feature = "arrow")]
1086 #[test]
1087 fn decode_parquet_bytes_yields_schema_and_batches() {
1088 let (schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1089 assert_eq!(schema.fields().len(), 2);
1090 assert_eq!(schema.field(0).name(), "id");
1091 let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1092 assert_eq!(total, 2);
1093 }
1094
1095 #[cfg(feature = "arrow")]
1096 #[test]
1097 fn parquet_batches_convert_to_rows() {
1098 let (_schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1099 let mut rows = Vec::new();
1100 for b in &batches {
1101 rows.extend(faucet_core::columnar::record_batch_to_values(b).unwrap());
1102 }
1103 assert_eq!(rows.len(), 2);
1104 assert_eq!(rows[0]["id"], 1);
1105 assert_eq!(rows[0]["name"], "Alice");
1106 assert!(rows[1].as_object().unwrap().contains_key("name"));
1108 assert!(rows[1]["name"].is_null());
1109 }
1110
1111 #[cfg(feature = "arrow")]
1112 #[test]
1113 fn corrupt_parquet_bytes_error() {
1114 let err = decode_parquet_bytes(bytes::Bytes::from_static(b"not parquet"), "bad.parquet")
1115 .unwrap_err();
1116 assert!(matches!(err, FaucetError::Source(_)));
1117 }
1118
1119 #[cfg(feature = "arrow")]
1120 #[test]
1121 fn supports_columnar_only_for_parquet_format() {
1122 let parquet_src = test_source(S3SourceConfig::new("b").file_format(S3FileFormat::Parquet));
1123 assert!(faucet_core::Source::supports_columnar(&parquet_src));
1124
1125 let json_src = test_source(S3SourceConfig::new("b"));
1126 assert!(!faucet_core::Source::supports_columnar(&json_src));
1127 }
1128}