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::{FaucetError, Stream, StreamPage};
7use futures::stream::{self, StreamExt, TryStreamExt};
8use google_cloud_gax::paginator::ItemPaginator;
9use google_cloud_storage::client::{Storage, StorageControl};
10use serde_json::Value;
11use std::pin::Pin;
12use tokio::io::AsyncBufReadExt;
13
14/// A GCS source that lists and reads objects from a bucket.
15pub struct GcsSource {
16    config: GcsSourceConfig,
17    storage: Storage,
18    control: StorageControl,
19}
20
21impl GcsSource {
22    /// Construct the source. Builds both clients eagerly so they are
23    /// reused across calls.
24    pub async fn new(config: GcsSourceConfig) -> Result<Self, FaucetError> {
25        let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
26        let control = build_storage_control(&config.auth, config.storage_host.as_deref()).await?;
27        Ok(Self {
28            config,
29            storage,
30            control,
31        })
32    }
33
34    /// Bucket as a GCS resource path: `projects/_/buckets/{bucket}`.
35    fn bucket_path(&self) -> String {
36        format!("projects/_/buckets/{}", self.config.bucket)
37    }
38
39    /// List object names under the configured (or override) prefix,
40    /// capped at `max_objects` if set.
41    async fn list_object_names(
42        &self,
43        prefix_override: Option<&str>,
44    ) -> Result<Vec<String>, FaucetError> {
45        if let Some(ref keys) = self.config.object_keys {
46            return Ok(cap_keys(keys.clone(), self.config.max_objects));
47        }
48
49        let effective_prefix = prefix_override.or(self.config.prefix.as_deref());
50        let mut req = self.control.list_objects().set_parent(self.bucket_path());
51        if let Some(p) = effective_prefix {
52            req = req.set_prefix(p.to_string());
53        }
54        req = req.set_page_size(1000_i32);
55
56        let mut paginator = req.by_item();
57        let mut names: Vec<String> = Vec::new();
58        while let Some(item) = paginator.next().await {
59            let object = item.map_err(|e| {
60                FaucetError::Source(format!(
61                    "GCS list error for bucket '{}': {e}",
62                    self.config.bucket
63                ))
64            })?;
65            if object.name.is_empty() {
66                continue;
67            }
68            names.push(object.name);
69            if let Some(max) = self.config.max_objects
70                && names.len() >= max
71            {
72                break;
73            }
74        }
75        Ok(names)
76    }
77
78    /// Read the full body of a single GCS object into a UTF-8 `String`.
79    async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
80        // Stream the (optionally decompressed) body straight into one String
81        // via the same reader the line-streaming path uses, instead of holding
82        // the raw bytes AND the decompressed bytes AND the String at once
83        // (#78/#25). For JsonArray / RawText the whole object is still one
84        // unit, but peak memory is now ~1× the decoded size rather than ~3×.
85        use tokio::io::AsyncReadExt as _;
86        let mut reader = self.open_object_reader(key).await?;
87        let mut text = String::new();
88        reader.read_to_string(&mut text).await.map_err(|e| {
89            FaucetError::Source(format!(
90                "GCS read/decode error for key '{key}' (not valid UTF-8?): {e}"
91            ))
92        })?;
93        Ok(text)
94    }
95
96    /// Open a GCS object as an `AsyncBufRead` over its body so callers can
97    /// decode line-by-line without buffering the entire object.
98    ///
99    /// Requires the `unstable-stream` feature on `google-cloud-storage`.
100    async fn open_object_reader(
101        &self,
102        key: &str,
103    ) -> Result<std::pin::Pin<Box<dyn tokio::io::AsyncBufRead + Send + Unpin>>, FaucetError> {
104        let resp = self
105            .storage
106            .read_object(self.bucket_path(), key.to_string())
107            .send()
108            .await
109            .map_err(|e| {
110                FaucetError::Source(format!(
111                    "GCS get error for bucket '{}' key '{key}': {e}",
112                    self.config.bucket
113                ))
114            })?;
115        // Read the object metadata (size, content-encoding, checksums) BEFORE
116        // consuming the stream, so a cleanly-truncated/corrupted transfer is
117        // rejected rather than silently parsed as a complete object (#161).
118        let highlights = resp.object();
119        let mut checks: Vec<Box<dyn faucet_core::IntegrityCheck>> = Vec::new();
120        match crate::verify::length_check(
121            highlights.size,
122            &highlights.content_encoding,
123            self.config.verify_length,
124        ) {
125            Some(check) => checks.push(check),
126            None if self.config.verify_length => tracing::debug!(
127                key = %key,
128                size = highlights.size,
129                content_encoding = %highlights.content_encoding,
130                "GCS object length verification skipped (no size or transcoded encoding)"
131            ),
132            None => {}
133        }
134        if self.config.verify_checksum {
135            let (crc32c, md5) = match &highlights.checksums {
136                Some(c) => (c.crc32c, c.md5_hash.clone()),
137                None => (None, bytes::Bytes::new()),
138            };
139            match crate::verify::checksum_check(crc32c, &md5, &highlights.content_encoding) {
140                Some(check) => checks.push(check),
141                None if highlights.content_encoding.is_empty() => tracing::warn!(
142                    key = %key,
143                    "verify_checksum is enabled but GCS advertised no verifiable checksum for \
144                     this object; relying on the length check only"
145                ),
146                None => {}
147            }
148        }
149
150        let bytes_stream = resp
151            .into_stream()
152            .map_err(|e| std::io::Error::other(e.to_string()));
153        // Wrap the RAW byte stream in the verifier first so length/checksum
154        // cover the stored bytes (below any client-side decompression).
155        let verified = faucet_core::VerifyingReader::new(
156            tokio_util::io::StreamReader::new(bytes_stream),
157            checks,
158        );
159        let buffered = tokio::io::BufReader::new(verified);
160        #[cfg(feature = "compression")]
161        {
162            let codec = self.config.compression.resolve(key);
163            faucet_core::compression::warn_mismatch(key, codec);
164            Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
165        }
166        #[cfg(not(feature = "compression"))]
167        {
168            Ok(Box::pin(buffered))
169        }
170    }
171
172    /// Parse file content into records based on the configured file format.
173    fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
174        parse_file_content(&self.config.file_format, key, text)
175    }
176}
177
178/// Parse file content into records for a given format. Free function (vs. a
179/// `GcsSource` method) so it is unit-testable without a GCS client — the
180/// parsing logic is pure. Previously this logic lived only inside the
181/// `parse_content` method and was duplicated by a copy in the test module;
182/// that copy could silently drift from production since the integration tests
183/// that would have caught it are `#[ignore]`d (no gRPC emulator exists).
184pub(crate) fn parse_file_content(
185    format: &GcsFileFormat,
186    key: &str,
187    text: &str,
188) -> Result<Vec<Value>, FaucetError> {
189    match format {
190        GcsFileFormat::JsonLines => {
191            let mut records = Vec::new();
192            for (line_num, line) in text.lines().enumerate() {
193                let trimmed = line.trim();
194                if trimmed.is_empty() {
195                    continue;
196                }
197                let value: Value = serde_json::from_str(trimmed).map_err(|e| {
198                    FaucetError::Source(format!(
199                        "GCS JSON parse error in '{key}' at line {}: {e}",
200                        line_num + 1
201                    ))
202                })?;
203                records.push(value);
204            }
205            Ok(records)
206        }
207        GcsFileFormat::JsonArray => {
208            let value: Value = serde_json::from_str(text).map_err(|e| {
209                FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
210            })?;
211            match value {
212                Value::Array(arr) => Ok(arr),
213                other => Err(FaucetError::Source(format!(
214                    "GCS expected JSON array in '{key}', got {}",
215                    value_type_name(&other)
216                ))),
217            }
218        }
219        GcsFileFormat::RawText => Ok(vec![serde_json::json!({
220            "key": key,
221            "content": text,
222        })]),
223    }
224}
225
226#[async_trait]
227impl faucet_core::Source for GcsSource {
228    async fn fetch_with_context(
229        &self,
230        context: &std::collections::HashMap<String, Value>,
231    ) -> Result<Vec<Value>, FaucetError> {
232        let substituted_prefix: Option<String> = if !context.is_empty() {
233            self.config
234                .prefix
235                .as_ref()
236                .map(|p| faucet_core::util::substitute_context(p, context))
237        } else {
238            None
239        };
240
241        let keys = self
242            .list_object_names(substituted_prefix.as_deref())
243            .await?;
244        tracing::info!(
245            bucket = %self.config.bucket,
246            objects = keys.len(),
247            "Listed GCS objects",
248        );
249
250        let concurrency = self.config.concurrency.max(1);
251        let results: Vec<Vec<Value>> = stream::iter(keys)
252            .map(|key| async move {
253                let text = self.read_object_text(&key).await?;
254                let records = self.parse_content(&key, &text)?;
255                tracing::debug!(key = %key, records = records.len(), "Read GCS object");
256                Ok::<Vec<Value>, FaucetError>(records)
257            })
258            .buffer_unordered(concurrency)
259            .try_collect()
260            .await?;
261
262        let all_records: Vec<Value> = results.into_iter().flatten().collect();
263        tracing::info!(total_records = all_records.len(), "GCS fetch complete");
264        Ok(all_records)
265    }
266
267    /// Stream records from listed GCS objects without buffering the full
268    /// scan. Mirrors `S3Source::stream_pages` — see that implementation
269    /// for the per-format reasoning.
270    fn stream_pages<'a>(
271        &'a self,
272        context: &'a std::collections::HashMap<String, Value>,
273        _batch_size: usize,
274    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
275        let batch_size = self.config.batch_size;
276
277        Box::pin(async_stream::try_stream! {
278            let substituted_prefix: Option<String> = if !context.is_empty() {
279                self.config
280                    .prefix
281                    .as_ref()
282                    .map(|p| faucet_core::util::substitute_context(p, context))
283            } else {
284                None
285            };
286
287            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
288            tracing::info!(
289                bucket = %self.config.bucket,
290                objects = keys.len(),
291                "Listed GCS objects (stream)",
292            );
293
294            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
295            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
296            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
297            let mut total = 0usize;
298
299            for key in &keys {
300                match self.config.file_format {
301                    GcsFileFormat::JsonLines => {
302                        let reader = self.open_object_reader(key).await?;
303                        let mut lines = reader.lines();
304                        let mut line_num: usize = 0;
305                        while let Some(line) = lines
306                            .next_line()
307                            .await
308                            .map_err(|e| FaucetError::Source(format!(
309                                "GCS read body error for key '{key}': {e}"
310                            )))?
311                        {
312                            line_num += 1;
313                            let trimmed = line.trim();
314                            if trimmed.is_empty() { continue; }
315                            let value: Value = serde_json::from_str(trimmed).map_err(|e| {
316                                FaucetError::Source(format!(
317                                    "GCS JSON parse error in '{key}' at line {line_num}: {e}",
318                                ))
319                            })?;
320                            buffer.push(value);
321                            if batch_size != 0 && buffer.len() >= chunk {
322                                let page = std::mem::replace(
323                                    &mut buffer,
324                                    Vec::with_capacity(initial_capacity),
325                                );
326                                total += page.len();
327                                yield StreamPage { records: page, bookmark: None };
328                            }
329                        }
330                        if batch_size == 0 && !buffer.is_empty() {
331                            let page = std::mem::take(&mut buffer);
332                            total += page.len();
333                            yield StreamPage { records: page, bookmark: None };
334                        }
335                    }
336                    GcsFileFormat::RawText => {
337                        let text = self.read_object_text(key).await?;
338                        let record = serde_json::json!({ "key": key, "content": text });
339                        buffer.push(record);
340                        if batch_size == 0 {
341                            let page = std::mem::take(&mut buffer);
342                            total += page.len();
343                            yield StreamPage { records: page, bookmark: None };
344                        } else if buffer.len() >= chunk {
345                            let page = std::mem::replace(
346                                &mut buffer,
347                                Vec::with_capacity(initial_capacity),
348                            );
349                            total += page.len();
350                            yield StreamPage { records: page, bookmark: None };
351                        }
352                    }
353                    GcsFileFormat::JsonArray => {
354                        let text = self.read_object_text(key).await?;
355                        let value: Value = serde_json::from_str(&text).map_err(|e| {
356                            FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
357                        })?;
358                        let array = match value {
359                            Value::Array(arr) => arr,
360                            other => Err(FaucetError::Source(format!(
361                                "GCS expected JSON array in '{key}', got {}",
362                                value_type_name(&other)
363                            )))?,
364                        };
365                        if batch_size == 0 {
366                            if !buffer.is_empty() {
367                                let page = std::mem::take(&mut buffer);
368                                total += page.len();
369                                yield StreamPage { records: page, bookmark: None };
370                            }
371                            total += array.len();
372                            yield StreamPage { records: array, bookmark: None };
373                        } else {
374                            for record in array {
375                                buffer.push(record);
376                                if buffer.len() >= chunk {
377                                    let page = std::mem::replace(
378                                        &mut buffer,
379                                        Vec::with_capacity(initial_capacity),
380                                    );
381                                    total += page.len();
382                                    yield StreamPage { records: page, bookmark: None };
383                                }
384                            }
385                        }
386                    }
387                }
388            }
389
390            if !buffer.is_empty() {
391                let page = std::mem::take(&mut buffer);
392                total += page.len();
393                yield StreamPage { records: page, bookmark: None };
394            }
395
396            tracing::info!(
397                total_records = total,
398                batch_size,
399                objects = keys.len(),
400                "GCS source stream complete",
401            );
402        })
403    }
404
405    fn config_schema(&self) -> Value {
406        serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
407            .expect("schema serialization")
408    }
409
410    fn connector_name(&self) -> &'static str {
411        "gcs"
412    }
413
414    fn dataset_uri(&self) -> String {
415        match &self.config.prefix {
416            Some(p) => format!("gs://{}/{}", self.config.bucket, p),
417            None => format!("gs://{}", self.config.bucket),
418        }
419    }
420}
421
422/// Truncate an explicit object-key list to the `max_objects` cap.
423///
424/// `None` leaves the list untouched; `Some(n)` keeps at most the first `n`
425/// keys. This mirrors the cap the listing path applies while paginating, so
426/// `max_objects` is honoured whether keys come from `object_keys` or a live
427/// `list_objects` scan.
428fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
429    if let Some(n) = max {
430        keys.truncate(n);
431    }
432    keys
433}
434
435fn value_type_name(v: &Value) -> &'static str {
436    match v {
437        Value::Null => "null",
438        Value::Bool(_) => "boolean",
439        Value::Number(_) => "number",
440        Value::String(_) => "string",
441        Value::Array(_) => "array",
442        Value::Object(_) => "object",
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use serde_json::json;
450
451    #[cfg(feature = "compression")]
452    #[test]
453    fn compression_default_is_auto() {
454        let cfg = GcsSourceConfig::new("bucket");
455        assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
456    }
457
458    #[test]
459    fn value_type_name_covers_all_json_variants() {
460        assert_eq!(value_type_name(&Value::Null), "null");
461        assert_eq!(value_type_name(&json!(true)), "boolean");
462        assert_eq!(value_type_name(&json!(7)), "number");
463        assert_eq!(value_type_name(&json!("s")), "string");
464        assert_eq!(value_type_name(&json!([1, 2])), "array");
465        assert_eq!(value_type_name(&json!({"k": 1})), "object");
466    }
467
468    #[test]
469    fn parse_json_lines() {
470        let r =
471            parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
472        assert_eq!(r.len(), 2);
473        assert_eq!(r[0]["id"], 1);
474    }
475
476    #[test]
477    fn parse_json_lines_skips_blanks() {
478        let r = parse_file_content(
479            &GcsFileFormat::JsonLines,
480            "t",
481            "{\"id\":1}\n\n{\"id\":2}\n\n",
482        )
483        .unwrap();
484        assert_eq!(r.len(), 2);
485    }
486
487    #[test]
488    fn parse_json_lines_reports_line_number() {
489        let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
490            .unwrap_err();
491        let msg = err.to_string();
492        assert!(msg.contains("line 2"), "unexpected: {msg}");
493    }
494
495    #[test]
496    fn parse_json_array() {
497        let r = parse_file_content(
498            &GcsFileFormat::JsonArray,
499            "t.json",
500            "[{\"id\":1},{\"id\":2}]",
501        )
502        .unwrap();
503        assert_eq!(r.len(), 2);
504    }
505
506    #[test]
507    fn parse_json_array_rejects_non_array() {
508        let err =
509            parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
510        assert!(err.to_string().contains("expected JSON array"));
511    }
512
513    #[test]
514    fn parse_raw_text_yields_single_record() {
515        let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
516        assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
517    }
518
519    #[test]
520    fn cap_keys_truncates_explicit_list_to_max_objects() {
521        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
522        let capped = cap_keys(keys, Some(2));
523        assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
524    }
525
526    #[test]
527    fn cap_keys_passes_through_when_no_max() {
528        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
529        let capped = cap_keys(keys.clone(), None);
530        assert_eq!(capped, keys);
531    }
532
533    #[test]
534    fn cap_keys_noop_when_max_exceeds_len() {
535        let keys = vec!["a".to_string(), "b".to_string()];
536        let capped = cap_keys(keys.clone(), Some(10));
537        assert_eq!(capped, keys);
538    }
539
540    // GcsSource requires an async constructor that tries to connect to GCS,
541    // so we verify the dataset_uri() logic directly via the config fields.
542    #[test]
543    fn dataset_uri_no_prefix_logic() {
544        let config = GcsSourceConfig::new("my-bucket");
545        let uri = match &config.prefix {
546            Some(p) => format!("gs://{}/{}", config.bucket, p),
547            None => format!("gs://{}", config.bucket),
548        };
549        assert_eq!(uri, "gs://my-bucket");
550    }
551
552    #[test]
553    fn dataset_uri_with_prefix_logic() {
554        let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
555        let uri = match &config.prefix {
556            Some(p) => format!("gs://{}/{}", config.bucket, p),
557            None => format!("gs://{}", config.bucket),
558        };
559        assert_eq!(uri, "gs://my-bucket/data/2026/");
560    }
561}