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