Skip to main content

faucet_source_gcs/
stream.rs

1//! GCS source stream executor.
2
3use crate::config::{GcsFileFormat, GcsSourceConfig};
4use async_trait::async_trait;
5use faucet_common_gcs::{build_storage, build_storage_control};
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 google_cloud_gax::paginator::ItemPaginator;
10use google_cloud_storage::client::{Storage, StorageControl};
11use serde_json::Value;
12use std::pin::Pin;
13use std::sync::Mutex;
14use tokio::io::AsyncBufReadExt;
15
16/// A GCS source that lists and reads objects from a bucket.
17pub struct GcsSource {
18    config: GcsSourceConfig,
19    storage: Storage,
20    control: StorageControl,
21    /// Shard applied by the cluster coordinator (Mode B). `None` (or a
22    /// degenerate single-shard set) reads every listed object. Stored behind a
23    /// `Mutex` so `apply_shard(&self, …)` can record it before streaming.
24    applied_shard: Mutex<Option<HashShard>>,
25}
26
27impl GcsSource {
28    /// Construct the source. Builds both clients eagerly so they are
29    /// reused across calls.
30    pub async fn new(config: GcsSourceConfig) -> Result<Self, FaucetError> {
31        config.validate()?;
32        let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
33        let control = build_storage_control(&config.auth, config.storage_host.as_deref()).await?;
34        Ok(Self {
35            config,
36            storage,
37            control,
38            applied_shard: Mutex::new(None),
39        })
40    }
41
42    /// Retain only the keys belonging to the applied shard (hash-of-key modulo
43    /// `shards`). A no-op when no shard is applied.
44    fn shard_filter(&self, keys: Vec<String>) -> Vec<String> {
45        filter_shard_keys(
46            keys,
47            *self.applied_shard.lock().expect("shard mutex poisoned"),
48        )
49    }
50
51    /// Bucket as a GCS resource path: `projects/_/buckets/{bucket}`.
52    fn bucket_path(&self) -> String {
53        format!("projects/_/buckets/{}", self.config.bucket)
54    }
55
56    /// List object names under the configured (or override) prefix,
57    /// capped at `max_objects` if set.
58    async fn list_object_names(
59        &self,
60        prefix_override: Option<&str>,
61    ) -> Result<Vec<String>, FaucetError> {
62        if let Some(ref keys) = self.config.object_keys {
63            return Ok(self.shard_filter(cap_keys(keys.clone(), self.config.max_objects)));
64        }
65
66        let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
67        let mut req = self.control.list_objects().set_parent(self.bucket_path());
68        if let Some(p) = effective_prefix {
69            req = req.set_prefix(p.to_string());
70        }
71        req = req.set_page_size(1000_i32);
72
73        let mut paginator = req.by_item();
74        let mut names: Vec<String> = Vec::new();
75        while let Some(item) = paginator.next().await {
76            let object = item.map_err(|e| {
77                FaucetError::Source(format!(
78                    "GCS list error for bucket '{}': {e}",
79                    self.config.bucket
80                ))
81            })?;
82            if object.name.is_empty() {
83                continue;
84            }
85            names.push(object.name);
86            if let Some(max) = self.config.max_objects
87                && names.len() >= max
88            {
89                break;
90            }
91        }
92        // Shard-filter AFTER the max_objects cap so the cap bounds the run's
93        // total object set (matching single-worker semantics) rather than
94        // multiplying by the shard count.
95        Ok(self.shard_filter(names))
96    }
97
98    /// Read the full body of a single GCS object into a UTF-8 `String`.
99    async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
100        // Stream the (optionally decompressed) body straight into one String
101        // via the same reader the line-streaming path uses, instead of holding
102        // the raw bytes AND the decompressed bytes AND the String at once
103        // (#78/#25). For JsonArray / RawText the whole object is still one
104        // unit, but peak memory is now ~1× the decoded size rather than ~3×.
105        use tokio::io::AsyncReadExt as _;
106        let mut reader = self.open_object_reader(key).await?;
107        let mut text = String::new();
108        reader.read_to_string(&mut text).await.map_err(|e| {
109            FaucetError::Source(format!(
110                "GCS read/decode error for key '{key}' (not valid UTF-8?): {e}"
111            ))
112        })?;
113        Ok(text)
114    }
115
116    /// Open a GCS object as an `AsyncBufRead` over its body so callers can
117    /// decode line-by-line without buffering the entire object.
118    ///
119    /// Requires the `unstable-stream` feature on `google-cloud-storage`.
120    async fn open_object_reader(
121        &self,
122        key: &str,
123    ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
124        let resp = self
125            .storage
126            .read_object(self.bucket_path(), key.to_string())
127            .send()
128            .await
129            .map_err(|e| {
130                FaucetError::Source(format!(
131                    "GCS get error for bucket '{}' key '{key}': {e}",
132                    self.config.bucket
133                ))
134            })?;
135        // Read the object metadata (size, content-encoding, checksums) BEFORE
136        // consuming the stream, so a cleanly-truncated/corrupted transfer is
137        // rejected rather than silently parsed as a complete object (#161).
138        let highlights = resp.object();
139        let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
140        match crate::verify::length_check(
141            highlights.size,
142            &highlights.content_encoding,
143            self.config.verify_length,
144        ) {
145            Some(check) => checks.push(check),
146            None if self.config.verify_length => tracing::debug!(
147                key = %key,
148                size = highlights.size,
149                content_encoding = %highlights.content_encoding,
150                "GCS object length verification skipped (no size or transcoded encoding)"
151            ),
152            None => {}
153        }
154        if self.config.verify_checksum {
155            let (crc32c, md5) = match &highlights.checksums {
156                Some(c) => (c.crc32c, c.md5_hash.clone()),
157                None => (None, bytes::Bytes::new()),
158            };
159            match crate::verify::checksum_check(crc32c, &md5, &highlights.content_encoding) {
160                Some(check) => checks.push(check),
161                None if highlights.content_encoding.is_empty() => tracing::warn!(
162                    key = %key,
163                    "verify_checksum is enabled but GCS advertised no verifiable checksum for \
164                     this object; relying on the length check only"
165                ),
166                None => {}
167            }
168        }
169
170        let bytes_stream = resp
171            .into_stream()
172            .map_err(|e| std::io::Error::other(e.to_string()));
173        // Wrap the RAW byte stream in the verifier first so length/checksum
174        // cover the stored bytes (below any client-side decompression).
175        let verified = faucet_core::VerifyingReader::new(
176            tokio_util::io::StreamReader::new(bytes_stream),
177            checks,
178        );
179        let buffered = tokio::io::BufReader::new(verified);
180        #[cfg(feature = "compression")]
181        {
182            let codec = self.config.compression.resolve(key);
183            faucet_core::compression::warn_mismatch(key, codec);
184            Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
185        }
186        #[cfg(not(feature = "compression"))]
187        {
188            Ok(Box::pin(buffered))
189        }
190    }
191
192    /// Parse file content into records based on the configured file format.
193    fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
194        parse_file_content(&self.config.file_format, key, text)
195    }
196
197    /// Download a single GCS object's full body into an in-memory
198    /// [`bytes::Bytes`], reusing [`open_object_reader`](Self::open_object_reader)
199    /// so length/checksum verification (and any configured decompression)
200    /// still apply. Used only by the Parquet path (Parquet is binary, so it
201    /// cannot go through [`read_object_text`](Self::read_object_text)).
202    #[cfg(feature = "arrow")]
203    async fn read_object_bytes(&self, key: &str) -> Result<bytes::Bytes, FaucetError> {
204        use tokio::io::AsyncReadExt as _;
205        let mut reader = self.open_object_reader(key).await?;
206        let mut buf = Vec::new();
207        reader
208            .read_to_end(&mut buf)
209            .await
210            .map_err(|e| FaucetError::Source(format!("GCS read error for key '{key}': {e}")))?;
211        Ok(bytes::Bytes::from(buf))
212    }
213
214    /// Decode a single Parquet object into its Arrow schema and batches. The
215    /// whole object is buffered (matching the `JsonArray` model) and decoded on
216    /// a blocking thread so the CPU-bound Parquet decode does not stall the
217    /// async runtime.
218    #[cfg(feature = "arrow")]
219    async fn read_object_parquet(
220        &self,
221        key: &str,
222    ) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
223        let data = self.read_object_bytes(key).await?;
224        let key_owned = key.to_string();
225        tokio::task::spawn_blocking(move || decode_parquet_bytes(data, &key_owned))
226            .await
227            .map_err(|e| {
228                FaucetError::Source(format!("parquet decode task for '{key}' panicked: {e}"))
229            })?
230    }
231}
232
233/// Parse file content into records for a given format. Free function (vs. a
234/// `GcsSource` method) so it is unit-testable without a GCS client — the
235/// parsing logic is pure. Previously this logic lived only inside the
236/// `parse_content` method and was duplicated by a copy in the test module;
237/// that copy could silently drift from production since the integration tests
238/// that would have caught it are `#[ignore]`d (no gRPC emulator exists).
239pub(crate) fn parse_file_content(
240    format: &GcsFileFormat,
241    key: &str,
242    text: &str,
243) -> Result<Vec<Value>, FaucetError> {
244    match format {
245        GcsFileFormat::JsonLines => {
246            let mut records = Vec::new();
247            for (line_num, line) in text.lines().enumerate() {
248                let trimmed = line.trim();
249                if trimmed.is_empty() {
250                    continue;
251                }
252                let value: Value = serde_json::from_str(trimmed).map_err(|e| {
253                    FaucetError::Source(format!(
254                        "GCS JSON parse error in '{key}' at line {}: {e}",
255                        line_num + 1
256                    ))
257                })?;
258                records.push(value);
259            }
260            Ok(records)
261        }
262        GcsFileFormat::JsonArray => {
263            let value: Value = serde_json::from_str(text).map_err(|e| {
264                FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
265            })?;
266            match value {
267                Value::Array(arr) => Ok(arr),
268                other => Err(FaucetError::Source(format!(
269                    "GCS expected JSON array in '{key}', got {}",
270                    value_type_name(&other)
271                ))),
272            }
273        }
274        GcsFileFormat::RawText => Ok(vec![serde_json::json!({
275            "key": key,
276            "content": text,
277        })]),
278        // Parquet is binary and is decoded via `read_object_parquet`, never
279        // through this text parser — reaching here is an internal invariant
280        // violation.
281        #[cfg(feature = "arrow")]
282        GcsFileFormat::Parquet => Err(FaucetError::Source(format!(
283            "GCS parquet object '{key}' cannot be parsed as text (internal error: \
284             parquet must use the binary decode path)"
285        ))),
286    }
287}
288
289/// Decode a fully-buffered Parquet object into its Arrow schema and batches.
290///
291/// Synchronous (runs inside `spawn_blocking`). `bytes::Bytes` implements
292/// `parquet`'s `ChunkReader`, so the in-memory reader needs no temp file. The
293/// schema is captured before the reader is consumed so an object with zero
294/// row-groups still reports a schema for cross-object consistency checks.
295#[cfg(feature = "arrow")]
296fn decode_parquet_bytes(
297    data: bytes::Bytes,
298    key: &str,
299) -> Result<(arrow::datatypes::SchemaRef, Vec<arrow::array::RecordBatch>), FaucetError> {
300    use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
301
302    let builder = ParquetRecordBatchReaderBuilder::try_new(data).map_err(|e| {
303        FaucetError::Source(format!("failed to read parquet metadata for '{key}': {e}"))
304    })?;
305    let schema = builder.schema().clone();
306    let reader = builder.build().map_err(|e| {
307        FaucetError::Source(format!("failed to build parquet reader for '{key}': {e}"))
308    })?;
309
310    let mut batches = Vec::new();
311    for batch in reader {
312        batches.push(
313            batch.map_err(|e| {
314                FaucetError::Source(format!("parquet decode error in '{key}': {e}"))
315            })?,
316        );
317    }
318    Ok((schema, batches))
319}
320
321#[async_trait]
322impl faucet_core::Source for GcsSource {
323    async fn fetch_with_context(
324        &self,
325        context: &std::collections::HashMap<String, Value>,
326    ) -> Result<Vec<Value>, FaucetError> {
327        let substituted_prefix: Option<String> = if !context.is_empty() {
328            self.config
329                .prefix
330                .as_ref()
331                .map(|p| faucet_core::util::substitute_context(p, context))
332        } else {
333            None
334        };
335
336        let keys = self
337            .list_object_names(substituted_prefix.as_deref())
338            .await?;
339        tracing::info!(
340            bucket = %self.config.bucket,
341            objects = keys.len(),
342            "Listed GCS objects",
343        );
344
345        let concurrency = self.config.concurrency.max(1);
346        let results: Vec<Vec<Value>> = stream::iter(keys)
347            .map(|key| async move {
348                #[cfg(feature = "arrow")]
349                if matches!(self.config.file_format, GcsFileFormat::Parquet) {
350                    let (_schema, batches) = self.read_object_parquet(&key).await?;
351                    let mut records = Vec::new();
352                    for batch in &batches {
353                        records.extend(faucet_core::columnar::record_batch_to_values(batch)?);
354                    }
355                    tracing::debug!(key = %key, records = records.len(), "Read GCS parquet object");
356                    return Ok::<Vec<Value>, FaucetError>(records);
357                }
358                let text = self.read_object_text(&key).await?;
359                let records = self.parse_content(&key, &text)?;
360                tracing::debug!(key = %key, records = records.len(), "Read GCS object");
361                Ok::<Vec<Value>, FaucetError>(records)
362            })
363            .buffer_unordered(concurrency)
364            .try_collect()
365            .await?;
366
367        let all_records: Vec<Value> = results.into_iter().flatten().collect();
368        tracing::info!(total_records = all_records.len(), "GCS fetch complete");
369        Ok(all_records)
370    }
371
372    /// Stream records from listed GCS objects without buffering the full
373    /// scan. Mirrors `S3Source::stream_pages` — see that implementation
374    /// for the per-format reasoning.
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            let substituted_prefix: Option<String> = if !context.is_empty() {
384                self.config
385                    .prefix
386                    .as_ref()
387                    .map(|p| faucet_core::util::substitute_context(p, context))
388            } else {
389                None
390            };
391
392            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
393            tracing::info!(
394                bucket = %self.config.bucket,
395                objects = keys.len(),
396                "Listed GCS objects (stream)",
397            );
398
399            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
400            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
401            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
402            let mut total = 0usize;
403
404            for key in &keys {
405                match self.config.file_format {
406                    GcsFileFormat::JsonLines => {
407                        let reader = self.open_object_reader(key).await?;
408                        let mut lines = reader.lines();
409                        let mut line_num: usize = 0;
410                        while let Some(line) = lines
411                            .next_line()
412                            .await
413                            .map_err(|e| FaucetError::Source(format!(
414                                "GCS read body error for key '{key}': {e}"
415                            )))?
416                        {
417                            line_num += 1;
418                            let trimmed = line.trim();
419                            if trimmed.is_empty() { continue; }
420                            let value: Value = serde_json::from_str(trimmed).map_err(|e| {
421                                FaucetError::Source(format!(
422                                    "GCS JSON parse error in '{key}' at line {line_num}: {e}",
423                                ))
424                            })?;
425                            buffer.push(value);
426                            if batch_size != 0 && buffer.len() >= chunk {
427                                let page = std::mem::replace(
428                                    &mut buffer,
429                                    Vec::with_capacity(initial_capacity),
430                                );
431                                total += page.len();
432                                yield StreamPage { records: page, bookmark: None };
433                            }
434                        }
435                        if batch_size == 0 && !buffer.is_empty() {
436                            let page = std::mem::take(&mut buffer);
437                            total += page.len();
438                            yield StreamPage { records: page, bookmark: None };
439                        }
440                    }
441                    GcsFileFormat::RawText => {
442                        let text = self.read_object_text(key).await?;
443                        let record = serde_json::json!({ "key": key, "content": text });
444                        buffer.push(record);
445                        if batch_size == 0 {
446                            let page = std::mem::take(&mut buffer);
447                            total += page.len();
448                            yield StreamPage { records: page, bookmark: None };
449                        } else if buffer.len() >= chunk {
450                            let page = std::mem::replace(
451                                &mut buffer,
452                                Vec::with_capacity(initial_capacity),
453                            );
454                            total += page.len();
455                            yield StreamPage { records: page, bookmark: None };
456                        }
457                    }
458                    #[cfg(feature = "arrow")]
459                    GcsFileFormat::Parquet => {
460                        // Parquet objects are buffered and decoded to Arrow
461                        // `RecordBatch`es, then converted to JSON rows for the
462                        // row path. Rows accumulate across objects and chunk at
463                        // `batch_size`; `batch_size == 0` emits one page per
464                        // object.
465                        let (_schema, batches) = self.read_object_parquet(key).await?;
466                        for batch in &batches {
467                            let rows = faucet_core::columnar::record_batch_to_values(batch)?;
468                            for record in rows {
469                                buffer.push(record);
470                                if batch_size != 0 && buffer.len() >= chunk {
471                                    let page = std::mem::replace(
472                                        &mut buffer,
473                                        Vec::with_capacity(initial_capacity),
474                                    );
475                                    total += page.len();
476                                    yield StreamPage { records: page, bookmark: None };
477                                }
478                            }
479                        }
480                        if batch_size == 0 && !buffer.is_empty() {
481                            let page = std::mem::take(&mut buffer);
482                            total += page.len();
483                            yield StreamPage { records: page, bookmark: None };
484                        }
485                    }
486                    GcsFileFormat::JsonArray => {
487                        let text = self.read_object_text(key).await?;
488                        let value: Value = serde_json::from_str(&text).map_err(|e| {
489                            FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
490                        })?;
491                        let array = match value {
492                            Value::Array(arr) => arr,
493                            other => Err(FaucetError::Source(format!(
494                                "GCS expected JSON array in '{key}', got {}",
495                                value_type_name(&other)
496                            )))?,
497                        };
498                        if batch_size == 0 {
499                            if !buffer.is_empty() {
500                                let page = std::mem::take(&mut buffer);
501                                total += page.len();
502                                yield StreamPage { records: page, bookmark: None };
503                            }
504                            total += array.len();
505                            yield StreamPage { records: array, bookmark: None };
506                        } else {
507                            for record in array {
508                                buffer.push(record);
509                                if buffer.len() >= chunk {
510                                    let page = std::mem::replace(
511                                        &mut buffer,
512                                        Vec::with_capacity(initial_capacity),
513                                    );
514                                    total += page.len();
515                                    yield StreamPage { records: page, bookmark: None };
516                                }
517                            }
518                        }
519                    }
520                }
521            }
522
523            if !buffer.is_empty() {
524                let page = std::mem::take(&mut buffer);
525                total += page.len();
526                yield StreamPage { records: page, bookmark: None };
527            }
528
529            tracing::info!(
530                total_records = total,
531                batch_size,
532                objects = keys.len(),
533                "GCS source stream complete",
534            );
535        })
536    }
537
538    /// The GCS source advertises the columnar fast path **only** when
539    /// configured for the [`Parquet`](GcsFileFormat::Parquet) format — the
540    /// text formats have no native Arrow representation and stay on the row
541    /// path (RFC 0002 / #375).
542    #[cfg(feature = "arrow")]
543    fn supports_columnar(&self) -> bool {
544        matches!(self.config.file_format, GcsFileFormat::Parquet)
545    }
546
547    /// Stream Parquet objects natively as Arrow `RecordBatch`es — one
548    /// [`ColumnarPage`](faucet_core::columnar::ColumnarPage) per batch — so a
549    /// `gcs(parquet) → parquet`/`delta`/`sql` chain never materializes
550    /// `serde_json::Value`. The first object's schema is the reference; a later
551    /// divergent object aborts after earlier objects' pages have been written
552    /// (the same non-atomic multi-object semantics the row path has). Empty
553    /// batches are skipped; every page carries `bookmark: None`.
554    #[cfg(feature = "arrow")]
555    fn stream_batches<'a>(
556        &'a self,
557        context: &'a std::collections::HashMap<String, Value>,
558        _batch_size: usize,
559    ) -> Pin<
560        Box<
561            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
562        >,
563    > {
564        Box::pin(async_stream::try_stream! {
565            if !matches!(self.config.file_format, GcsFileFormat::Parquet) {
566                Err(FaucetError::Source(
567                    "GCS source: stream_batches invoked for a non-parquet file_format".into(),
568                ))?;
569            }
570
571            let substituted_prefix: Option<String> = if !context.is_empty() {
572                self.config
573                    .prefix
574                    .as_ref()
575                    .map(|p| faucet_core::util::substitute_context(p, context))
576            } else {
577                None
578            };
579
580            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
581            tracing::info!(
582                bucket = %self.config.bucket,
583                objects = keys.len(),
584                "Listed GCS objects (columnar stream)",
585            );
586
587            let mut reference: Option<arrow::datatypes::SchemaRef> = None;
588            let mut total_records = 0usize;
589            let mut total_pages = 0usize;
590            for key in &keys {
591                let (schema, batches) = self.read_object_parquet(key).await?;
592                match &reference {
593                    Some(first) if first != &schema => {
594                        Err(FaucetError::Source(format!(
595                            "GCS source: parquet schema mismatch — object '{key}' diverges from \
596                             the first object's schema"
597                        )))?;
598                    }
599                    None => reference = Some(schema),
600                    _ => {}
601                }
602                for batch in batches {
603                    if batch.num_rows() == 0 {
604                        continue;
605                    }
606                    total_records += batch.num_rows();
607                    total_pages += 1;
608                    yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
609                }
610            }
611
612            tracing::info!(
613                pages = total_pages,
614                total_records,
615                objects = keys.len(),
616                "GCS source columnar stream complete",
617            );
618        })
619    }
620
621    fn config_schema(&self) -> Value {
622        serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
623            .expect("schema serialization")
624    }
625
626    fn connector_name(&self) -> &'static str {
627        "gcs"
628    }
629
630    fn dataset_uri(&self) -> String {
631        match &self.config.prefix {
632            Some(p) => format!("gs://{}/{}", self.config.bucket, p),
633            None => format!("gs://{}", self.config.bucket),
634        }
635    }
636
637    /// The GCS source is always shardable: any object set can be split by
638    /// hash-of-key. Sharding only takes effect when the cluster coordinator
639    /// calls `apply_shard`; a plain `faucet run` reads every object.
640    fn is_shardable(&self) -> bool {
641        true
642    }
643
644    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
645    /// objects whose key hashes to `i (mod target)`. No I/O: the partition is
646    /// defined by the hash function, so enumeration is cheap and stable as new
647    /// objects appear. `target <= 1` yields a single whole-dataset shard.
648    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
649        Ok(plan_hash_shards(target))
650    }
651
652    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
653    /// clears any filter (reads every object).
654    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
655        *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "gcs")?;
656        Ok(())
657    }
658
659    fn supports_discover(&self) -> bool {
660        true
661    }
662
663    /// Enumerate the "directories" directly under the configured prefix via
664    /// **one** delimiter (`/`) listing page — each common prefix becomes a
665    /// `prefix` dataset. When the listing returns no common prefixes but does
666    /// return objects directly under the prefix, each object (first page
667    /// only, ≤ `DISCOVER_MAX_OBJECTS`) becomes an `object` dataset instead,
668    /// selected via the exact-match `object_keys` config field. No recursion
669    /// and no data scan — object counts would require paging the whole
670    /// listing, so `estimated_rows` is never set.
671    async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
672        let mut req = self
673            .control
674            .list_objects()
675            .set_parent(self.bucket_path())
676            .set_delimiter("/")
677            .set_page_size(DISCOVER_MAX_OBJECTS as i32);
678        if let Some(p) = self.config.prefix.as_deref() {
679            req = req.set_prefix(p.to_string());
680        }
681        let response = req
682            .send()
683            .await
684            .map_err(|e| FaucetError::Source(format!("gcs: catalog discovery failed: {e}")))?;
685
686        let objects: Vec<String> = response
687            .objects
688            .into_iter()
689            .map(|o| o.name)
690            .filter(|n| !n.is_empty())
691            .collect();
692        Ok(descriptors_from_listing(response.prefixes, objects))
693    }
694}
695
696/// Cap on object-fallback descriptors — one delimiter-listing page, matching
697/// the `page_size` requested from GCS.
698const DISCOVER_MAX_OBJECTS: usize = 1000;
699
700/// Build one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per common
701/// prefix from a single delimiter listing; when the listing yielded no common
702/// prefixes, fall back to one descriptor per object (capped at
703/// `DISCOVER_MAX_OBJECTS`). Prefix datasets patch the source's `prefix`
704/// config field; object datasets patch `object_keys` (which selects exactly
705/// that object and makes `prefix` inert). Pure — unit-testable without a GCS
706/// client.
707fn descriptors_from_listing(
708    prefixes: Vec<String>,
709    objects: Vec<String>,
710) -> Vec<faucet_core::DatasetDescriptor> {
711    let prefixes: Vec<String> = prefixes.into_iter().filter(|p| !p.is_empty()).collect();
712    if !prefixes.is_empty() {
713        return prefixes
714            .into_iter()
715            .map(|p| {
716                let patch = serde_json::json!({ "prefix": p });
717                faucet_core::DatasetDescriptor::new(p, "prefix", patch)
718            })
719            .collect();
720    }
721    objects
722        .into_iter()
723        .take(DISCOVER_MAX_OBJECTS)
724        .map(|k| {
725            let patch = serde_json::json!({ "object_keys": [k] });
726            faucet_core::DatasetDescriptor::new(k, "object", patch)
727        })
728        .collect()
729}
730
731/// Truncate an explicit object-key list to the `max_objects` cap.
732///
733/// `None` leaves the list untouched; `Some(n)` keeps at most the first `n`
734/// keys. This mirrors the cap the listing path applies while paginating, so
735/// `max_objects` is honoured whether keys come from `object_keys` or a live
736/// `list_objects` scan.
737fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
738    if let Some(n) = max {
739        keys.truncate(n);
740    }
741    keys
742}
743
744/// Retain only the keys owned by `shard` (hash-of-key modulo `shards`). Free
745/// function (vs. a `GcsSource` method) so the partitioning logic is
746/// unit-testable without a GCS client — constructing the source requires live
747/// credentials, and the gRPC integration tests are `#[ignore]`d (#220).
748fn filter_shard_keys(keys: Vec<String>, shard: Option<HashShard>) -> Vec<String> {
749    match shard {
750        Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
751        None => keys,
752    }
753}
754
755fn value_type_name(v: &Value) -> &'static str {
756    match v {
757        Value::Null => "null",
758        Value::Bool(_) => "boolean",
759        Value::Number(_) => "number",
760        Value::String(_) => "string",
761        Value::Array(_) => "array",
762        Value::Object(_) => "object",
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use serde_json::json;
770
771    #[cfg(feature = "compression")]
772    #[test]
773    fn compression_default_is_auto() {
774        let cfg = GcsSourceConfig::new("bucket");
775        assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
776    }
777
778    #[test]
779    fn value_type_name_covers_all_json_variants() {
780        assert_eq!(value_type_name(&Value::Null), "null");
781        assert_eq!(value_type_name(&json!(true)), "boolean");
782        assert_eq!(value_type_name(&json!(7)), "number");
783        assert_eq!(value_type_name(&json!("s")), "string");
784        assert_eq!(value_type_name(&json!([1, 2])), "array");
785        assert_eq!(value_type_name(&json!({"k": 1})), "object");
786    }
787
788    #[test]
789    fn parse_json_lines() {
790        let r =
791            parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
792        assert_eq!(r.len(), 2);
793        assert_eq!(r[0]["id"], 1);
794    }
795
796    #[test]
797    fn parse_json_lines_skips_blanks() {
798        let r = parse_file_content(
799            &GcsFileFormat::JsonLines,
800            "t",
801            "{\"id\":1}\n\n{\"id\":2}\n\n",
802        )
803        .unwrap();
804        assert_eq!(r.len(), 2);
805    }
806
807    #[test]
808    fn parse_json_lines_reports_line_number() {
809        let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
810            .unwrap_err();
811        let msg = err.to_string();
812        assert!(msg.contains("line 2"), "unexpected: {msg}");
813    }
814
815    #[test]
816    fn parse_json_array() {
817        let r = parse_file_content(
818            &GcsFileFormat::JsonArray,
819            "t.json",
820            "[{\"id\":1},{\"id\":2}]",
821        )
822        .unwrap();
823        assert_eq!(r.len(), 2);
824    }
825
826    #[test]
827    fn parse_json_array_rejects_non_array() {
828        let err =
829            parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
830        assert!(err.to_string().contains("expected JSON array"));
831    }
832
833    #[test]
834    fn parse_raw_text_yields_single_record() {
835        let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
836        assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
837    }
838
839    #[test]
840    fn cap_keys_truncates_explicit_list_to_max_objects() {
841        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
842        let capped = cap_keys(keys, Some(2));
843        assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
844    }
845
846    #[test]
847    fn cap_keys_passes_through_when_no_max() {
848        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
849        let capped = cap_keys(keys.clone(), None);
850        assert_eq!(capped, keys);
851    }
852
853    #[test]
854    fn cap_keys_noop_when_max_exceeds_len() {
855        let keys = vec!["a".to_string(), "b".to_string()];
856        let capped = cap_keys(keys.clone(), Some(10));
857        assert_eq!(capped, keys);
858    }
859
860    // ── Hash-modulo sharding (Mode B, #262) ──────────────────────────────────
861
862    // The union of every shard's filtered key set equals the full set, with no
863    // key in two shards — the core no-dup / no-loss guarantee.
864    #[test]
865    fn shard_filter_partitions_keys_disjointly_and_completely() {
866        let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
867        let members: Vec<HashShard> = plan_hash_shards(4)
868            .iter()
869            .map(|s| HashShard::from_spec(s).expect("descriptor parses"))
870            .collect();
871        let mut union: Vec<String> = Vec::new();
872        for member in members {
873            union.extend(filter_shard_keys(keys.clone(), Some(member)));
874        }
875        union.sort();
876        let mut expected = keys.clone();
877        expected.sort();
878        assert_eq!(
879            union, expected,
880            "shards must union to the full key set, disjointly"
881        );
882    }
883
884    #[test]
885    fn no_applied_shard_reads_everything() {
886        let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
887        assert_eq!(filter_shard_keys(keys.clone(), None), keys);
888    }
889
890    // ── discover: pure listing → descriptor mapping ─────────────────────────
891
892    #[test]
893    fn descriptors_from_listing_maps_common_prefixes() {
894        let out = descriptors_from_listing(
895            vec!["raw/orders/".to_string(), "raw/users/".to_string()],
896            vec![],
897        );
898        assert_eq!(out.len(), 2);
899        assert_eq!(out[0].name, "raw/orders/");
900        assert_eq!(out[0].kind, "prefix");
901        assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
902        assert!(out[0].schema.is_none());
903        assert!(out[0].estimated_rows.is_none());
904        assert_eq!(out[1].name, "raw/users/");
905        assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
906    }
907
908    // Prefixes win: objects sitting alongside common prefixes are not
909    // enumerated as datasets (they'd be a mixed listing at the same level).
910    #[test]
911    fn descriptors_from_listing_prefers_prefixes_over_objects() {
912        let out = descriptors_from_listing(
913            vec!["raw/orders/".to_string()],
914            vec!["raw/readme.txt".to_string()],
915        );
916        assert_eq!(out.len(), 1);
917        assert_eq!(out[0].kind, "prefix");
918        assert_eq!(out[0].name, "raw/orders/");
919    }
920
921    #[test]
922    fn descriptors_from_listing_falls_back_to_objects_via_object_keys() {
923        let out = descriptors_from_listing(
924            vec![],
925            vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
926        );
927        assert_eq!(out.len(), 2);
928        assert_eq!(out[0].name, "raw/a.jsonl");
929        assert_eq!(out[0].kind, "object");
930        assert_eq!(
931            out[0].config_patch,
932            json!({ "object_keys": ["raw/a.jsonl"] })
933        );
934        assert!(out[0].schema.is_none());
935        assert!(out[0].estimated_rows.is_none());
936    }
937
938    #[test]
939    fn descriptors_from_listing_empty_listing_yields_no_datasets() {
940        assert!(descriptors_from_listing(vec![], vec![]).is_empty());
941    }
942
943    #[test]
944    fn descriptors_from_listing_skips_empty_prefixes() {
945        let out = descriptors_from_listing(vec![String::new(), "raw/orders/".to_string()], vec![]);
946        assert_eq!(out.len(), 1);
947        assert_eq!(out[0].name, "raw/orders/");
948    }
949
950    #[test]
951    fn descriptors_from_listing_caps_object_fallback() {
952        let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
953            .map(|i| format!("obj-{i}.jsonl"))
954            .collect();
955        let out = descriptors_from_listing(vec![], objects);
956        assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
957    }
958
959    // GcsSource requires an async constructor that tries to connect to GCS,
960    // so we verify the dataset_uri() logic directly via the config fields.
961    #[test]
962    fn dataset_uri_no_prefix_logic() {
963        let config = GcsSourceConfig::new("my-bucket");
964        let uri = match &config.prefix {
965            Some(p) => format!("gs://{}/{}", config.bucket, p),
966            None => format!("gs://{}", config.bucket),
967        };
968        assert_eq!(uri, "gs://my-bucket");
969    }
970
971    #[test]
972    fn dataset_uri_with_prefix_logic() {
973        let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
974        let uri = match &config.prefix {
975            Some(p) => format!("gs://{}/{}", config.bucket, p),
976            None => format!("gs://{}", config.bucket),
977        };
978        assert_eq!(uri, "gs://my-bucket/data/2026/");
979    }
980
981    // ── Parquet columnar path (feature `arrow`) ──────────────────────────────
982
983    #[cfg(feature = "arrow")]
984    fn sample_parquet_bytes() -> bytes::Bytes {
985        use arrow::array::{Int32Array, RecordBatch, StringArray};
986        use arrow::datatypes::{DataType, Field, Schema};
987        use std::sync::Arc;
988
989        let schema = Arc::new(Schema::new(vec![
990            Field::new("id", DataType::Int32, false),
991            Field::new("name", DataType::Utf8, true),
992        ]));
993        let batch = RecordBatch::try_new(
994            schema.clone(),
995            vec![
996                Arc::new(Int32Array::from(vec![10, 20])),
997                Arc::new(StringArray::from(vec![Some("x"), None])),
998            ],
999        )
1000        .unwrap();
1001        let mut buf: Vec<u8> = Vec::new();
1002        {
1003            let mut writer = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
1004            writer.write(&batch).unwrap();
1005            writer.close().unwrap();
1006        }
1007        bytes::Bytes::from(buf)
1008    }
1009
1010    #[cfg(feature = "arrow")]
1011    #[test]
1012    fn decode_parquet_bytes_yields_schema_and_batches() {
1013        let (schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1014        assert_eq!(schema.fields().len(), 2);
1015        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
1016        assert_eq!(total, 2);
1017    }
1018
1019    #[cfg(feature = "arrow")]
1020    #[test]
1021    fn parquet_batches_convert_to_rows_with_explicit_nulls() {
1022        let (_schema, batches) = decode_parquet_bytes(sample_parquet_bytes(), "t.parquet").unwrap();
1023        let mut rows = Vec::new();
1024        for b in &batches {
1025            rows.extend(faucet_core::columnar::record_batch_to_values(b).unwrap());
1026        }
1027        assert_eq!(rows.len(), 2);
1028        assert_eq!(rows[0]["id"], 10);
1029        assert_eq!(rows[0]["name"], "x");
1030        assert!(rows[1].as_object().unwrap().contains_key("name"));
1031        assert!(rows[1]["name"].is_null());
1032    }
1033
1034    #[cfg(feature = "arrow")]
1035    #[test]
1036    fn corrupt_parquet_bytes_error() {
1037        let err =
1038            decode_parquet_bytes(bytes::Bytes::from_static(b"nope"), "bad.parquet").unwrap_err();
1039        assert!(matches!(err, FaucetError::Source(_)));
1040    }
1041}