Skip to main content

faucet_source_s3/
stream.rs

1//! S3 source stream executor.
2
3use 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
14/// An S3 source that lists and reads objects from a bucket.
15pub struct S3Source {
16    config: S3SourceConfig,
17    client: Client,
18    /// Shard applied by the cluster coordinator (Mode B). `None` (or a
19    /// degenerate single-shard set) reads every listed object. Stored behind a
20    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
21    applied_shard: Mutex<Option<HashShard>>,
22}
23
24impl S3Source {
25    /// Create a new S3 source from the given configuration.
26    ///
27    /// Builds the S3 client eagerly so it is reused across calls.
28    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    /// Retain only the keys belonging to the applied shard (hash-of-key modulo
38    /// `shards`). A no-op when no shard is applied or `shards <= 1`.
39    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    /// Build an S3 client from the configuration.
47    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    /// List object keys matching the configured bucket and prefix.
64    ///
65    /// When `prefix_override` is `Some`, it is used instead of `self.config.prefix`
66    /// (used for parent-context substitution).
67    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    /// Read and parse a single S3 object into records.
119    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    /// Download a single S3 object's full body into an in-memory
134    /// [`bytes::Bytes`], reusing [`open_object_reader`](Self::open_object_reader)
135    /// so length/checksum verification (and any configured decompression)
136    /// still apply. Used only by the Parquet path, which needs the raw bytes
137    /// (Parquet is binary, so it cannot go through
138    /// [`read_object_text`](Self::read_object_text)).
139    #[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    /// Decode a single Parquet object into its Arrow schema and the list of
152    /// `RecordBatch`es it contains. The whole object is buffered (matching the
153    /// connector's `JsonArray` model) and decoded on a blocking thread so the
154    /// CPU-bound Parquet decode does not stall the async runtime.
155    #[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    /// Read the full body of a single S3 object into a UTF-8 `String`.
170    ///
171    /// Streams the (optionally decompressed) body straight into one `String`
172    /// via [`open_object_reader`](Self::open_object_reader) rather than
173    /// buffering the raw bytes AND the decompressed bytes AND the `String`
174    /// at once (#78/#25). The whole object is still one unit for
175    /// `JsonArray` / `RawText`, but peak memory is now ~1× the decoded size.
176    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    /// Open an S3 object as an [`AsyncBufRead`](tokio::io::AsyncBufRead) over
189    /// its body. Used by [`Source::stream_pages`](faucet_core::Source::stream_pages)
190    /// to decode `JsonLines` objects line-by-line without buffering the
191    /// whole file.
192    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        // Ask S3 to return its stored checksum so we can verify the body (#161).
202        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        // Read all metadata BEFORE consuming `body` (which partially moves
210        // `response`), so a cleanly-truncated/corrupted transfer is rejected
211        // rather than silently parsed as a complete object (#161).
212        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        // `ByteStream::into_async_read` returns `impl AsyncRead`. Wrap the RAW
240        // body in the verifier first so length/checksum cover the stored bytes
241        // (below any decompression), then `BufReader` so `.lines()` is usable
242        // and ownership is `Unpin`.
243        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    /// Parse file content into records based on the configured file format.
258    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            // Parquet is binary and is decoded via `read_object_parquet`, which
297            // never routes through this text parser — reaching here is an
298            // internal invariant violation.
299            #[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        // Substitute context into prefix when parent context is provided.
315        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    /// Stream records from listed S3 objects without buffering the full
351    /// scan. Each emitted [`StreamPage`] holds up to
352    /// [`S3SourceConfig::batch_size`] records.
353    ///
354    /// The trait-level `batch_size` argument is ignored in favour of the
355    /// config field — the config is the user-facing knob the README
356    /// documents, and routing the pipeline-supplied hint through it would
357    /// silently override an explicit config value.
358    ///
359    /// Behaviour by format:
360    ///
361    /// - `JsonLines` / `RawText`: the object body is decoded line-by-line
362    ///   via [`tokio::io::AsyncBufReadExt::lines`] so client-side memory is
363    ///   bounded at `O(batch_size)` per object. Multi-object scans are
364    ///   flattened — a single page may carry lines drawn from any object.
365    /// - `JsonArray`: each object is buffered fully (the JSON value can
366    ///   only be parsed once the array is complete) and then its records
367    ///   are chunked into pages of `batch_size`. See the README "Streaming
368    ///   and batching" section for the caveat.
369    ///
370    /// `batch_size = 0` is the "no batching" sentinel: one [`StreamPage`]
371    /// is emitted per S3 object (no within-object chunking and no
372    /// cross-object accumulation). The S3 source has no
373    /// incremental-replication mode today, so every emitted page carries
374    /// `bookmark: None`.
375    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            // Substitute context into prefix when parent context is provided.
384            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                        // RawText emits a single record per object; the
447                        // `key` + `content` shape is unchanged so we
448                        // continue to buffer the body fully. This still
449                        // streams *across* objects.
450                        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                        // Parquet objects are buffered and decoded to Arrow
472                        // `RecordBatch`es, then converted to JSON rows for the
473                        // row path. Rows accumulate across objects and chunk at
474                        // `batch_size`; `batch_size == 0` emits one page per
475                        // object.
476                        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                        // JSON-array files cannot be parsed incrementally
499                        // (the closing `]` is required to validate the
500                        // structure), so each object is buffered fully and
501                        // then chunked. The caveat is documented in the
502                        // crate README.
503                        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                            // Flush any cross-object buffer first (none
516                            // here because each iteration completes its
517                            // own object — but keep symmetric with the
518                            // line-shaped branches).
519                            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    /// The S3 source advertises the columnar fast path **only** when configured
559    /// for the [`Parquet`](S3FileFormat::Parquet) format — the text formats
560    /// (`JsonLines` / `JsonArray` / `RawText`) have no native Arrow
561    /// representation and stay on the row path (RFC 0002 / #375).
562    #[cfg(feature = "arrow")]
563    fn supports_columnar(&self) -> bool {
564        matches!(self.config.file_format, S3FileFormat::Parquet)
565    }
566
567    /// Stream Parquet objects natively as Arrow `RecordBatch`es — one
568    /// [`ColumnarPage`](faucet_core::columnar::ColumnarPage) per batch — so an
569    /// `s3(parquet) → parquet`/`delta`/`sql` chain never materializes
570    /// `serde_json::Value`.
571    ///
572    /// Objects are read in listing order. The first object's Arrow schema is
573    /// the reference; a later object whose schema diverges surfaces as
574    /// [`FaucetError::Source`]. Because each object is buffered and decoded as
575    /// it is reached (not probed up front), a divergent *later* object aborts
576    /// after earlier objects' pages have already been written — the same
577    /// non-atomic multi-object semantics the row path already has. Empty
578    /// batches are skipped; every page carries `bookmark: None` (the S3 source
579    /// has no incremental-replication mode).
580    #[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    /// The S3 source is always shardable: any object set can be split by
664    /// hash-of-key. Sharding only takes effect when the cluster coordinator
665    /// calls `apply_shard`; a plain `faucet run` reads
666    /// every object.
667    fn is_shardable(&self) -> bool {
668        true
669    }
670
671    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
672    /// objects whose key hashes to `i (mod target)`. No I/O: the partition is
673    /// defined by the hash function, so enumeration is cheap and stable as new
674    /// objects appear. `target <= 1` yields a single whole-dataset shard.
675    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
676        Ok(plan_hash_shards(target))
677    }
678
679    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
680    /// clears any filter (reads every object).
681    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    /// Enumerate the "directories" directly under the configured prefix via
691    /// **one** `ListObjectsV2` delimiter (`/`) listing — each common prefix
692    /// becomes a `prefix` dataset. When the listing returns no common
693    /// prefixes but does return objects directly under the prefix, each
694    /// object (first page only, capped at `DISCOVER_MAX_OBJECTS` = 1000) becomes an
695    /// `object` dataset instead. No recursion and no data scan — object
696    /// counts would require paging the whole listing, so `estimated_rows`
697    /// is never set.
698    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
732/// Cap on object-fallback descriptors — one delimiter-listing page, matching
733/// the `max_keys` requested from S3.
734const DISCOVER_MAX_OBJECTS: usize = 1000;
735
736/// Build one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per common
737/// prefix from a single delimiter listing; when the listing yielded no common
738/// prefixes, fall back to one descriptor per object (capped at
739/// `DISCOVER_MAX_OBJECTS`). Each patch selects the dataset via the source's
740/// `prefix` config field — a full object key used as a prefix selects exactly
741/// that object. Pure — unit-testable without an S3 client.
742fn 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/// Decode a fully-buffered Parquet object into its Arrow schema and batches.
766///
767/// Synchronous (runs inside `spawn_blocking`). `bytes::Bytes` implements
768/// `parquet`'s `ChunkReader`, so the in-memory reader needs no temp file. The
769/// schema is captured before the reader is consumed so an object with zero
770/// row-groups still reports a schema for cross-object consistency checks.
771#[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
797/// Return a human-readable name for a JSON value type.
798fn 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    /// Helper to build an S3Source synchronously for parse-only tests.
817    /// We construct it directly to avoid needing an async runtime for unit tests
818    /// that only exercise `parse_content`.
819    fn test_source(config: S3SourceConfig) -> S3Source {
820        // Build a dummy client — these tests never make network calls.
821        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    // ── Hash-modulo sharding ────────────────────────────────────────────────
906
907    #[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    // The union of every shard's filtered key set equals the full set, with no
938    // key in two shards — the core no-dup / no-loss guarantee.
939    #[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    // ── discover: pure listing → descriptor mapping ─────────────────────────
984
985    #[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    // Prefixes win: objects sitting alongside common prefixes are not
1002    // enumerated as datasets (they'd be a mixed listing at the same level).
1003    #[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    // ── Parquet columnar path (feature `arrow`) ──────────────────────────────
1061
1062    #[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        // Explicit-null field survives the round-trip (#321 H6).
1111        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}