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 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    /// The S3 source is always shardable: any object set can be split by
660    /// hash-of-key. Sharding only takes effect when the cluster coordinator
661    /// calls `apply_shard`; a plain `faucet run` reads
662    /// every object.
663    fn is_shardable(&self) -> bool {
664        true
665    }
666
667    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
668    /// objects whose key hashes to `i (mod target)`. No I/O: the partition is
669    /// defined by the hash function, so enumeration is cheap and stable as new
670    /// objects appear. `target <= 1` yields a single whole-dataset shard.
671    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
672        Ok(plan_hash_shards(target))
673    }
674
675    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
676    /// clears any filter (reads every object).
677    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    /// Enumerate the "directories" directly under the configured prefix via
687    /// **one** `ListObjectsV2` delimiter (`/`) listing — each common prefix
688    /// becomes a `prefix` dataset. When the listing returns no common
689    /// prefixes but does return objects directly under the prefix, each
690    /// object (first page only, capped at `DISCOVER_MAX_OBJECTS` = 1000) becomes an
691    /// `object` dataset instead. No recursion and no data scan — object
692    /// counts would require paging the whole listing, so `estimated_rows`
693    /// is never set.
694    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
728/// Cap on object-fallback descriptors — one delimiter-listing page, matching
729/// the `max_keys` requested from S3.
730const DISCOVER_MAX_OBJECTS: usize = 1000;
731
732/// Build one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per common
733/// prefix from a single delimiter listing; when the listing yielded no common
734/// prefixes, fall back to one descriptor per object (capped at
735/// `DISCOVER_MAX_OBJECTS`). Each patch selects the dataset via the source's
736/// `prefix` config field — a full object key used as a prefix selects exactly
737/// that object. Pure — unit-testable without an S3 client.
738fn 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/// Decode a fully-buffered Parquet object into its Arrow schema and batches.
762///
763/// Synchronous (runs inside `spawn_blocking`). `bytes::Bytes` implements
764/// `parquet`'s `ChunkReader`, so the in-memory reader needs no temp file. The
765/// schema is captured before the reader is consumed so an object with zero
766/// row-groups still reports a schema for cross-object consistency checks.
767#[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
793/// Return a human-readable name for a JSON value type.
794fn 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    /// Helper to build an S3Source synchronously for parse-only tests.
813    /// We construct it directly to avoid needing an async runtime for unit tests
814    /// that only exercise `parse_content`.
815    fn test_source(config: S3SourceConfig) -> S3Source {
816        // Build a dummy client — these tests never make network calls.
817        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    // ── Hash-modulo sharding ────────────────────────────────────────────────
902
903    #[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    // The union of every shard's filtered key set equals the full set, with no
934    // key in two shards — the core no-dup / no-loss guarantee.
935    #[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    // ── discover: pure listing → descriptor mapping ─────────────────────────
980
981    #[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    // Prefixes win: objects sitting alongside common prefixes are not
998    // enumerated as datasets (they'd be a mixed listing at the same level).
999    #[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    // ── Parquet columnar path (feature `arrow`) ──────────────────────────────
1057
1058    #[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        // Explicit-null field survives the round-trip (#321 H6).
1107        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}