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 let text = self.read_object_text(key).await?;
121 self.parse_content(key, &text)
122 }
123
124 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
132 use tokio::io::AsyncReadExt as _;
133 let mut reader = self.open_object_reader(key).await?;
134 let mut text = String::new();
135 reader.read_to_string(&mut text).await.map_err(|e| {
136 FaucetError::Source(format!(
137 "S3 read/decode error for key '{key}' (not valid UTF-8?): {e}"
138 ))
139 })?;
140 Ok(text)
141 }
142
143 async fn open_object_reader(
148 &self,
149 key: &str,
150 ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
151 let mut request = self
152 .client
153 .get_object()
154 .bucket(&self.config.bucket)
155 .key(key);
156 if self.config.verify_checksum {
158 request = request.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled);
159 }
160 let response = request.send().await.map_err(|e| {
161 FaucetError::Source(format!("S3 get object error for key '{key}': {e}"))
162 })?;
163
164 let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
168 match crate::verify::length_check(response.content_length(), self.config.verify_length) {
169 Some(check) => checks.push(check),
170 None if self.config.verify_length => tracing::debug!(
171 key = %key,
172 "S3 object reports no Content-Length; length verification skipped"
173 ),
174 None => {}
175 }
176 if self.config.verify_checksum {
177 let advertised = crate::verify::S3Checksums {
178 crc32: response.checksum_crc32().map(str::to_string),
179 crc32c: response.checksum_crc32_c().map(str::to_string),
180 crc64nvme: response.checksum_crc64_nvme().map(str::to_string),
181 sha256: response.checksum_sha256().map(str::to_string),
182 etag: response.e_tag().map(str::to_string),
183 };
184 match crate::verify::checksum_check(&advertised) {
185 Some(check) => checks.push(check),
186 None => tracing::warn!(
187 key = %key,
188 "verify_checksum is enabled but S3 advertised no verifiable checksum for \
189 this object; relying on the length check only"
190 ),
191 }
192 }
193
194 let verified = faucet_core::VerifyingReader::new(response.body.into_async_read(), checks);
199 let buffered = tokio::io::BufReader::new(verified);
200 #[cfg(feature = "compression")]
201 {
202 let codec = self.config.compression.resolve(key);
203 faucet_core::compression::warn_mismatch(key, codec);
204 Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
205 }
206 #[cfg(not(feature = "compression"))]
207 {
208 Ok(Box::pin(buffered))
209 }
210 }
211
212 fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
214 match self.config.file_format {
215 S3FileFormat::JsonLines => {
216 let mut records = Vec::new();
217 for (line_num, line) in text.lines().enumerate() {
218 let trimmed = line.trim();
219 if trimmed.is_empty() {
220 continue;
221 }
222 let value: Value = serde_json::from_str(trimmed).map_err(|e| {
223 FaucetError::Source(format!(
224 "S3 JSON parse error in '{key}' at line {}: {e}",
225 line_num + 1
226 ))
227 })?;
228 records.push(value);
229 }
230 Ok(records)
231 }
232 S3FileFormat::JsonArray => {
233 let value: Value = serde_json::from_str(text).map_err(|e| {
234 FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
235 })?;
236 match value {
237 Value::Array(arr) => Ok(arr),
238 _ => Err(FaucetError::Source(format!(
239 "S3 expected JSON array in '{key}', got {}",
240 value_type_name(&value)
241 ))),
242 }
243 }
244 S3FileFormat::RawText => {
245 let record = serde_json::json!({
246 "key": key,
247 "content": text,
248 });
249 Ok(vec![record])
250 }
251 }
252 }
253}
254
255#[async_trait]
256impl faucet_core::Source for S3Source {
257 async fn fetch_with_context(
258 &self,
259 context: &std::collections::HashMap<String, serde_json::Value>,
260 ) -> Result<Vec<Value>, FaucetError> {
261 let substituted_prefix: Option<String> = if !context.is_empty() {
263 self.config
264 .prefix
265 .as_ref()
266 .map(|p| faucet_core::util::substitute_context(p, context))
267 } else {
268 None
269 };
270
271 let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
272
273 tracing::info!(
274 bucket = %self.config.bucket,
275 objects = keys.len(),
276 "Listed S3 objects"
277 );
278
279 let concurrency = self.config.concurrency.max(1);
280
281 let results: Vec<Vec<Value>> = stream::iter(keys)
282 .map(|key| async move {
283 let records = self.read_object(&key).await?;
284 tracing::debug!(key = %key, records = records.len(), "Read S3 object");
285 Ok::<Vec<Value>, FaucetError>(records)
286 })
287 .buffer_unordered(concurrency)
288 .try_collect()
289 .await?;
290
291 let all_records: Vec<Value> = results.into_iter().flatten().collect();
292
293 tracing::info!(total_records = all_records.len(), "S3 fetch complete");
294 Ok(all_records)
295 }
296
297 fn stream_pages<'a>(
323 &'a self,
324 context: &'a std::collections::HashMap<String, Value>,
325 _batch_size: usize,
326 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
327 let batch_size = self.config.batch_size;
328
329 Box::pin(async_stream::try_stream! {
330 let substituted_prefix: Option<String> = if !context.is_empty() {
332 self.config
333 .prefix
334 .as_ref()
335 .map(|p| faucet_core::util::substitute_context(p, context))
336 } else {
337 None
338 };
339
340 let keys = self.list_object_keys(substituted_prefix.as_deref()).await?;
341 tracing::info!(
342 bucket = %self.config.bucket,
343 objects = keys.len(),
344 "Listed S3 objects (stream)",
345 );
346
347 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
348 let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
349 let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
350 let mut total = 0usize;
351
352 for key in &keys {
353 match self.config.file_format {
354 S3FileFormat::JsonLines => {
355 let reader = self.open_object_reader(key).await?;
356 let mut lines = reader.lines();
357 let mut line_num: usize = 0;
358 while let Some(line) = lines
359 .next_line()
360 .await
361 .map_err(|e| FaucetError::Source(format!(
362 "S3 read body error for key '{key}': {e}"
363 )))?
364 {
365 line_num += 1;
366 let trimmed = line.trim();
367 if trimmed.is_empty() {
368 continue;
369 }
370 let value: Value =
371 serde_json::from_str(trimmed).map_err(|e| {
372 FaucetError::Source(format!(
373 "S3 JSON parse error in '{key}' at line {line_num}: {e}",
374 ))
375 })?;
376 buffer.push(value);
377 if batch_size != 0 && buffer.len() >= chunk {
378 let page = std::mem::replace(
379 &mut buffer,
380 Vec::with_capacity(initial_capacity),
381 );
382 total += page.len();
383 yield StreamPage { records: page, bookmark: None };
384 }
385 }
386 if batch_size == 0 && !buffer.is_empty() {
387 let page = std::mem::take(&mut buffer);
388 total += page.len();
389 yield StreamPage { records: page, bookmark: None };
390 }
391 }
392 S3FileFormat::RawText => {
393 let text = self.read_object_text(key).await?;
398 let record = serde_json::json!({
399 "key": key,
400 "content": text,
401 });
402 buffer.push(record);
403 if batch_size == 0 {
404 let page = std::mem::take(&mut buffer);
405 total += page.len();
406 yield StreamPage { records: page, bookmark: None };
407 } else if buffer.len() >= chunk {
408 let page = std::mem::replace(
409 &mut buffer,
410 Vec::with_capacity(initial_capacity),
411 );
412 total += page.len();
413 yield StreamPage { records: page, bookmark: None };
414 }
415 }
416 S3FileFormat::JsonArray => {
417 let text = self.read_object_text(key).await?;
423 let value: Value = serde_json::from_str(&text).map_err(|e| {
424 FaucetError::Source(format!("S3 JSON parse error in '{key}': {e}"))
425 })?;
426 let array = match value {
427 Value::Array(arr) => arr,
428 other => Err(FaucetError::Source(format!(
429 "S3 expected JSON array in '{key}', got {}",
430 value_type_name(&other)
431 )))?,
432 };
433 if batch_size == 0 {
434 if !buffer.is_empty() {
439 let page = std::mem::take(&mut buffer);
440 total += page.len();
441 yield StreamPage { records: page, bookmark: None };
442 }
443 total += array.len();
444 yield StreamPage { records: array, bookmark: None };
445 } else {
446 for record in array {
447 buffer.push(record);
448 if buffer.len() >= chunk {
449 let page = std::mem::replace(
450 &mut buffer,
451 Vec::with_capacity(initial_capacity),
452 );
453 total += page.len();
454 yield StreamPage { records: page, bookmark: None };
455 }
456 }
457 }
458 }
459 }
460 }
461
462 if !buffer.is_empty() {
463 let page = std::mem::take(&mut buffer);
464 total += page.len();
465 yield StreamPage { records: page, bookmark: None };
466 }
467
468 tracing::info!(
469 total_records = total,
470 batch_size,
471 objects = keys.len(),
472 "S3 source stream complete",
473 );
474 })
475 }
476
477 fn config_schema(&self) -> serde_json::Value {
478 serde_json::to_value(faucet_core::schema_for!(S3SourceConfig))
479 .expect("schema serialization")
480 }
481
482 fn dataset_uri(&self) -> String {
483 match &self.config.prefix {
484 Some(p) => format!("s3://{}/{}", self.config.bucket, p),
485 None => format!("s3://{}", self.config.bucket),
486 }
487 }
488
489 fn is_shardable(&self) -> bool {
494 true
495 }
496
497 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
502 Ok(plan_hash_shards(target))
503 }
504
505 async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
508 *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "s3")?;
509 Ok(())
510 }
511}
512
513fn value_type_name(v: &Value) -> &'static str {
515 match v {
516 Value::Null => "null",
517 Value::Bool(_) => "boolean",
518 Value::Number(_) => "number",
519 Value::String(_) => "string",
520 Value::Array(_) => "array",
521 Value::Object(_) => "object",
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::config::S3SourceConfig;
529 use faucet_core::Source;
530 use serde_json::json;
531
532 fn test_source(config: S3SourceConfig) -> S3Source {
536 let sdk_config = aws_config::SdkConfig::builder()
538 .behavior_version(aws_config::BehaviorVersion::latest())
539 .build();
540 let client = Client::new(&sdk_config);
541 S3Source {
542 config,
543 client,
544 applied_shard: Mutex::new(None),
545 }
546 }
547
548 #[test]
549 fn parse_json_lines() {
550 let source = test_source(S3SourceConfig::new("test"));
551 let text = r#"{"id":1,"name":"Alice"}
552{"id":2,"name":"Bob"}
553"#;
554 let records = source.parse_content("test.jsonl", text).unwrap();
555 assert_eq!(records.len(), 2);
556 assert_eq!(records[0]["id"], 1);
557 assert_eq!(records[1]["name"], "Bob");
558 }
559
560 #[test]
561 fn parse_json_lines_skips_empty() {
562 let source = test_source(S3SourceConfig::new("test"));
563 let text = r#"{"id":1}
564
565{"id":2}
566
567"#;
568 let records = source.parse_content("test.jsonl", text).unwrap();
569 assert_eq!(records.len(), 2);
570 }
571
572 #[test]
573 fn parse_json_lines_invalid() {
574 let source = test_source(S3SourceConfig::new("test"));
575 let text = "not json\n";
576 let result = source.parse_content("test.jsonl", text);
577 assert!(result.is_err());
578 let err = result.unwrap_err().to_string();
579 assert!(err.contains("JSON parse error"));
580 assert!(err.contains("line 1"));
581 }
582
583 #[test]
584 fn parse_json_array() {
585 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
586 let text = r#"[{"id":1},{"id":2}]"#;
587 let records = source.parse_content("test.json", text).unwrap();
588 assert_eq!(records.len(), 2);
589 assert_eq!(records[0]["id"], 1);
590 }
591
592 #[test]
593 fn parse_json_array_not_array() {
594 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
595 let text = r#"{"id":1}"#;
596 let result = source.parse_content("test.json", text);
597 assert!(result.is_err());
598 let err = result.unwrap_err().to_string();
599 assert!(err.contains("expected JSON array"));
600 }
601
602 #[test]
603 fn parse_raw_text() {
604 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::RawText));
605 let text = "hello world\nline two";
606 let records = source.parse_content("data/file.txt", text).unwrap();
607 assert_eq!(records.len(), 1);
608 assert_eq!(
609 records[0],
610 json!({"key": "data/file.txt", "content": "hello world\nline two"})
611 );
612 }
613
614 #[cfg(feature = "compression")]
615 #[test]
616 fn compression_default_is_auto() {
617 let cfg = S3SourceConfig::new("bucket");
618 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
619 }
620
621 #[test]
624 fn shard_hash_is_deterministic() {
625 use faucet_core::shard::shard_hash;
626 assert_eq!(
627 shard_hash("data/part-001.jsonl"),
628 shard_hash("data/part-001.jsonl")
629 );
630 assert_ne!(shard_hash("a"), shard_hash("b"));
631 }
632
633 #[tokio::test]
634 async fn enumerate_shards_returns_target_disjoint_shards() {
635 let source = test_source(S3SourceConfig::new("b"));
636 assert!(source.is_shardable());
637 let shards = source.enumerate_shards(3).await.unwrap();
638 assert_eq!(shards.len(), 3);
639 for (i, s) in shards.iter().enumerate() {
640 assert_eq!(s.descriptor["shards"], 3);
641 assert_eq!(s.descriptor["index"], i);
642 }
643 }
644
645 #[tokio::test]
646 async fn enumerate_shards_target_one_is_whole() {
647 let source = test_source(S3SourceConfig::new("b"));
648 let shards = source.enumerate_shards(1).await.unwrap();
649 assert_eq!(shards.len(), 1);
650 assert!(shards[0].is_whole());
651 }
652
653 #[tokio::test]
656 async fn shard_filter_partitions_keys_disjointly_and_completely() {
657 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
658 let n = 4;
659 let mut union: Vec<String> = Vec::new();
660 for index in 0..n {
661 let source = test_source(S3SourceConfig::new("b"));
662 source
663 .apply_shard(&ShardSpec::new(
664 index.to_string(),
665 serde_json::json!({ "shards": n, "index": index }),
666 ))
667 .await
668 .unwrap();
669 let got = source.shard_filter(keys.clone());
670 union.extend(got);
671 }
672 union.sort();
673 let mut expected = keys.clone();
674 expected.sort();
675 assert_eq!(
676 union, expected,
677 "shards must union to the full key set, disjointly"
678 );
679 }
680
681 #[tokio::test]
682 async fn apply_whole_shard_reads_everything() {
683 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
684 let source = test_source(S3SourceConfig::new("b"));
685 source.apply_shard(&ShardSpec::whole()).await.unwrap();
686 assert_eq!(source.shard_filter(keys.clone()).len(), keys.len());
687 }
688
689 #[tokio::test]
690 async fn apply_shard_rejects_malformed_descriptor() {
691 let source = test_source(S3SourceConfig::new("b"));
692 let err = source
693 .apply_shard(&ShardSpec::new("0", serde_json::json!({ "index": 0 })))
694 .await
695 .unwrap_err();
696 assert!(matches!(err, FaucetError::Source(_)));
697 }
698
699 #[test]
700 fn dataset_uri_no_prefix() {
701 let source = test_source(S3SourceConfig::new("my-bucket"));
702 assert_eq!(source.dataset_uri(), "s3://my-bucket");
703 }
704
705 #[test]
706 fn dataset_uri_with_prefix() {
707 let source = test_source(S3SourceConfig::new("my-bucket").prefix("data/2026/"));
708 assert_eq!(source.dataset_uri(), "s3://my-bucket/data/2026/");
709 }
710}