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 fn supports_discover(&self) -> bool {
513 true
514 }
515
516 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
525 let mut req = self
526 .client
527 .list_objects_v2()
528 .bucket(&self.config.bucket)
529 .delimiter("/")
530 .max_keys(DISCOVER_MAX_OBJECTS as i32);
531 if let Some(prefix) = self.config.prefix.as_deref() {
532 req = req.prefix(prefix);
533 }
534 let response = req
535 .send()
536 .await
537 .map_err(|e| FaucetError::Source(format!("s3: catalog discovery failed: {e}")))?;
538
539 let prefixes: Vec<String> = response
540 .common_prefixes()
541 .iter()
542 .filter_map(|p| p.prefix())
543 .filter(|p| !p.is_empty())
544 .map(str::to_string)
545 .collect();
546 let objects: Vec<String> = response
547 .contents()
548 .iter()
549 .filter_map(|o| o.key())
550 .filter(|k| !k.is_empty())
551 .map(str::to_string)
552 .collect();
553
554 Ok(descriptors_from_listing(prefixes, objects))
555 }
556}
557
558const DISCOVER_MAX_OBJECTS: usize = 1000;
561
562fn descriptors_from_listing(
569 prefixes: Vec<String>,
570 objects: Vec<String>,
571) -> Vec<faucet_core::DatasetDescriptor> {
572 if !prefixes.is_empty() {
573 return prefixes
574 .into_iter()
575 .map(|p| {
576 let patch = serde_json::json!({ "prefix": p });
577 faucet_core::DatasetDescriptor::new(p, "prefix", patch)
578 })
579 .collect();
580 }
581 objects
582 .into_iter()
583 .take(DISCOVER_MAX_OBJECTS)
584 .map(|k| {
585 let patch = serde_json::json!({ "prefix": k });
586 faucet_core::DatasetDescriptor::new(k, "object", patch)
587 })
588 .collect()
589}
590
591fn value_type_name(v: &Value) -> &'static str {
593 match v {
594 Value::Null => "null",
595 Value::Bool(_) => "boolean",
596 Value::Number(_) => "number",
597 Value::String(_) => "string",
598 Value::Array(_) => "array",
599 Value::Object(_) => "object",
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::config::S3SourceConfig;
607 use faucet_core::Source;
608 use serde_json::json;
609
610 fn test_source(config: S3SourceConfig) -> S3Source {
614 let sdk_config = aws_config::SdkConfig::builder()
616 .behavior_version(aws_config::BehaviorVersion::latest())
617 .build();
618 let client = Client::new(&sdk_config);
619 S3Source {
620 config,
621 client,
622 applied_shard: Mutex::new(None),
623 }
624 }
625
626 #[test]
627 fn parse_json_lines() {
628 let source = test_source(S3SourceConfig::new("test"));
629 let text = r#"{"id":1,"name":"Alice"}
630{"id":2,"name":"Bob"}
631"#;
632 let records = source.parse_content("test.jsonl", text).unwrap();
633 assert_eq!(records.len(), 2);
634 assert_eq!(records[0]["id"], 1);
635 assert_eq!(records[1]["name"], "Bob");
636 }
637
638 #[test]
639 fn parse_json_lines_skips_empty() {
640 let source = test_source(S3SourceConfig::new("test"));
641 let text = r#"{"id":1}
642
643{"id":2}
644
645"#;
646 let records = source.parse_content("test.jsonl", text).unwrap();
647 assert_eq!(records.len(), 2);
648 }
649
650 #[test]
651 fn parse_json_lines_invalid() {
652 let source = test_source(S3SourceConfig::new("test"));
653 let text = "not json\n";
654 let result = source.parse_content("test.jsonl", text);
655 assert!(result.is_err());
656 let err = result.unwrap_err().to_string();
657 assert!(err.contains("JSON parse error"));
658 assert!(err.contains("line 1"));
659 }
660
661 #[test]
662 fn parse_json_array() {
663 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
664 let text = r#"[{"id":1},{"id":2}]"#;
665 let records = source.parse_content("test.json", text).unwrap();
666 assert_eq!(records.len(), 2);
667 assert_eq!(records[0]["id"], 1);
668 }
669
670 #[test]
671 fn parse_json_array_not_array() {
672 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::JsonArray));
673 let text = r#"{"id":1}"#;
674 let result = source.parse_content("test.json", text);
675 assert!(result.is_err());
676 let err = result.unwrap_err().to_string();
677 assert!(err.contains("expected JSON array"));
678 }
679
680 #[test]
681 fn parse_raw_text() {
682 let source = test_source(S3SourceConfig::new("test").file_format(S3FileFormat::RawText));
683 let text = "hello world\nline two";
684 let records = source.parse_content("data/file.txt", text).unwrap();
685 assert_eq!(records.len(), 1);
686 assert_eq!(
687 records[0],
688 json!({"key": "data/file.txt", "content": "hello world\nline two"})
689 );
690 }
691
692 #[cfg(feature = "compression")]
693 #[test]
694 fn compression_default_is_auto() {
695 let cfg = S3SourceConfig::new("bucket");
696 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
697 }
698
699 #[test]
702 fn shard_hash_is_deterministic() {
703 use faucet_core::shard::shard_hash;
704 assert_eq!(
705 shard_hash("data/part-001.jsonl"),
706 shard_hash("data/part-001.jsonl")
707 );
708 assert_ne!(shard_hash("a"), shard_hash("b"));
709 }
710
711 #[tokio::test]
712 async fn enumerate_shards_returns_target_disjoint_shards() {
713 let source = test_source(S3SourceConfig::new("b"));
714 assert!(source.is_shardable());
715 let shards = source.enumerate_shards(3).await.unwrap();
716 assert_eq!(shards.len(), 3);
717 for (i, s) in shards.iter().enumerate() {
718 assert_eq!(s.descriptor["shards"], 3);
719 assert_eq!(s.descriptor["index"], i);
720 }
721 }
722
723 #[tokio::test]
724 async fn enumerate_shards_target_one_is_whole() {
725 let source = test_source(S3SourceConfig::new("b"));
726 let shards = source.enumerate_shards(1).await.unwrap();
727 assert_eq!(shards.len(), 1);
728 assert!(shards[0].is_whole());
729 }
730
731 #[tokio::test]
734 async fn shard_filter_partitions_keys_disjointly_and_completely() {
735 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
736 let n = 4;
737 let mut union: Vec<String> = Vec::new();
738 for index in 0..n {
739 let source = test_source(S3SourceConfig::new("b"));
740 source
741 .apply_shard(&ShardSpec::new(
742 index.to_string(),
743 serde_json::json!({ "shards": n, "index": index }),
744 ))
745 .await
746 .unwrap();
747 let got = source.shard_filter(keys.clone());
748 union.extend(got);
749 }
750 union.sort();
751 let mut expected = keys.clone();
752 expected.sort();
753 assert_eq!(
754 union, expected,
755 "shards must union to the full key set, disjointly"
756 );
757 }
758
759 #[tokio::test]
760 async fn apply_whole_shard_reads_everything() {
761 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
762 let source = test_source(S3SourceConfig::new("b"));
763 source.apply_shard(&ShardSpec::whole()).await.unwrap();
764 assert_eq!(source.shard_filter(keys.clone()).len(), keys.len());
765 }
766
767 #[tokio::test]
768 async fn apply_shard_rejects_malformed_descriptor() {
769 let source = test_source(S3SourceConfig::new("b"));
770 let err = source
771 .apply_shard(&ShardSpec::new("0", serde_json::json!({ "index": 0 })))
772 .await
773 .unwrap_err();
774 assert!(matches!(err, FaucetError::Source(_)));
775 }
776
777 #[test]
780 fn descriptors_from_listing_maps_common_prefixes() {
781 let out = descriptors_from_listing(
782 vec!["raw/orders/".to_string(), "raw/users/".to_string()],
783 vec![],
784 );
785 assert_eq!(out.len(), 2);
786 assert_eq!(out[0].name, "raw/orders/");
787 assert_eq!(out[0].kind, "prefix");
788 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
789 assert!(out[0].schema.is_none());
790 assert!(out[0].estimated_rows.is_none());
791 assert_eq!(out[1].name, "raw/users/");
792 assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
793 }
794
795 #[test]
798 fn descriptors_from_listing_prefers_prefixes_over_objects() {
799 let out = descriptors_from_listing(
800 vec!["raw/orders/".to_string()],
801 vec!["raw/readme.txt".to_string()],
802 );
803 assert_eq!(out.len(), 1);
804 assert_eq!(out[0].kind, "prefix");
805 assert_eq!(out[0].name, "raw/orders/");
806 }
807
808 #[test]
809 fn descriptors_from_listing_falls_back_to_objects() {
810 let out = descriptors_from_listing(
811 vec![],
812 vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
813 );
814 assert_eq!(out.len(), 2);
815 assert_eq!(out[0].name, "raw/a.jsonl");
816 assert_eq!(out[0].kind, "object");
817 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/a.jsonl" }));
818 assert!(out[0].schema.is_none());
819 assert!(out[0].estimated_rows.is_none());
820 }
821
822 #[test]
823 fn descriptors_from_listing_empty_listing_yields_no_datasets() {
824 assert!(descriptors_from_listing(vec![], vec![]).is_empty());
825 }
826
827 #[test]
828 fn descriptors_from_listing_caps_object_fallback() {
829 let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
830 .map(|i| format!("obj-{i}.jsonl"))
831 .collect();
832 let out = descriptors_from_listing(vec![], objects);
833 assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
834 }
835
836 #[test]
837 fn source_advertises_discover() {
838 let source = test_source(S3SourceConfig::new("my-bucket"));
839 assert!(source.supports_discover());
840 }
841
842 #[test]
843 fn dataset_uri_no_prefix() {
844 let source = test_source(S3SourceConfig::new("my-bucket"));
845 assert_eq!(source.dataset_uri(), "s3://my-bucket");
846 }
847
848 #[test]
849 fn dataset_uri_with_prefix() {
850 let source = test_source(S3SourceConfig::new("my-bucket").prefix("data/2026/"));
851 assert_eq!(source.dataset_uri(), "s3://my-bucket/data/2026/");
852 }
853}