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        let bytes_stream = resp
116            .into_stream()
117            .map_err(|e| std::io::Error::other(e.to_string()));
118        let buffered = tokio::io::BufReader::new(tokio_util::io::StreamReader::new(bytes_stream));
119        #[cfg(feature = "compression")]
120        {
121            let codec = self.config.compression.resolve(key);
122            faucet_core::compression::warn_mismatch(key, codec);
123            Ok(faucet_core::compression::wrap_async_reader(buffered, codec))
124        }
125        #[cfg(not(feature = "compression"))]
126        {
127            Ok(Box::pin(buffered))
128        }
129    }
130
131    /// Parse file content into records based on the configured file format.
132    fn parse_content(&self, key: &str, text: &str) -> Result<Vec<Value>, FaucetError> {
133        parse_file_content(&self.config.file_format, key, text)
134    }
135}
136
137/// Parse file content into records for a given format. Free function (vs. a
138/// `GcsSource` method) so it is unit-testable without a GCS client — the
139/// parsing logic is pure. Previously this logic lived only inside the
140/// `parse_content` method and was duplicated by a copy in the test module;
141/// that copy could silently drift from production since the integration tests
142/// that would have caught it are `#[ignore]`d (no gRPC emulator exists).
143pub(crate) fn parse_file_content(
144    format: &GcsFileFormat,
145    key: &str,
146    text: &str,
147) -> Result<Vec<Value>, FaucetError> {
148    match format {
149        GcsFileFormat::JsonLines => {
150            let mut records = Vec::new();
151            for (line_num, line) in text.lines().enumerate() {
152                let trimmed = line.trim();
153                if trimmed.is_empty() {
154                    continue;
155                }
156                let value: Value = serde_json::from_str(trimmed).map_err(|e| {
157                    FaucetError::Source(format!(
158                        "GCS JSON parse error in '{key}' at line {}: {e}",
159                        line_num + 1
160                    ))
161                })?;
162                records.push(value);
163            }
164            Ok(records)
165        }
166        GcsFileFormat::JsonArray => {
167            let value: Value = serde_json::from_str(text).map_err(|e| {
168                FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
169            })?;
170            match value {
171                Value::Array(arr) => Ok(arr),
172                other => Err(FaucetError::Source(format!(
173                    "GCS expected JSON array in '{key}', got {}",
174                    value_type_name(&other)
175                ))),
176            }
177        }
178        GcsFileFormat::RawText => Ok(vec![serde_json::json!({
179            "key": key,
180            "content": text,
181        })]),
182    }
183}
184
185#[async_trait]
186impl faucet_core::Source for GcsSource {
187    async fn fetch_with_context(
188        &self,
189        context: &std::collections::HashMap<String, Value>,
190    ) -> Result<Vec<Value>, FaucetError> {
191        let substituted_prefix: Option<String> = if !context.is_empty() {
192            self.config
193                .prefix
194                .as_ref()
195                .map(|p| faucet_core::util::substitute_context(p, context))
196        } else {
197            None
198        };
199
200        let keys = self
201            .list_object_names(substituted_prefix.as_deref())
202            .await?;
203        tracing::info!(
204            bucket = %self.config.bucket,
205            objects = keys.len(),
206            "Listed GCS objects",
207        );
208
209        let concurrency = self.config.concurrency.max(1);
210        let results: Vec<Vec<Value>> = stream::iter(keys)
211            .map(|key| async move {
212                let text = self.read_object_text(&key).await?;
213                let records = self.parse_content(&key, &text)?;
214                tracing::debug!(key = %key, records = records.len(), "Read GCS object");
215                Ok::<Vec<Value>, FaucetError>(records)
216            })
217            .buffer_unordered(concurrency)
218            .try_collect()
219            .await?;
220
221        let all_records: Vec<Value> = results.into_iter().flatten().collect();
222        tracing::info!(total_records = all_records.len(), "GCS fetch complete");
223        Ok(all_records)
224    }
225
226    /// Stream records from listed GCS objects without buffering the full
227    /// scan. Mirrors `S3Source::stream_pages` — see that implementation
228    /// for the per-format reasoning.
229    fn stream_pages<'a>(
230        &'a self,
231        context: &'a std::collections::HashMap<String, Value>,
232        _batch_size: usize,
233    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
234        let batch_size = self.config.batch_size;
235
236        Box::pin(async_stream::try_stream! {
237            let substituted_prefix: Option<String> = if !context.is_empty() {
238                self.config
239                    .prefix
240                    .as_ref()
241                    .map(|p| faucet_core::util::substitute_context(p, context))
242            } else {
243                None
244            };
245
246            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
247            tracing::info!(
248                bucket = %self.config.bucket,
249                objects = keys.len(),
250                "Listed GCS objects (stream)",
251            );
252
253            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
254            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
255            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
256            let mut total = 0usize;
257
258            for key in &keys {
259                match self.config.file_format {
260                    GcsFileFormat::JsonLines => {
261                        let reader = self.open_object_reader(key).await?;
262                        let mut lines = reader.lines();
263                        let mut line_num: usize = 0;
264                        while let Some(line) = lines
265                            .next_line()
266                            .await
267                            .map_err(|e| FaucetError::Source(format!(
268                                "GCS read body error for key '{key}': {e}"
269                            )))?
270                        {
271                            line_num += 1;
272                            let trimmed = line.trim();
273                            if trimmed.is_empty() { continue; }
274                            let value: Value = serde_json::from_str(trimmed).map_err(|e| {
275                                FaucetError::Source(format!(
276                                    "GCS JSON parse error in '{key}' at line {line_num}: {e}",
277                                ))
278                            })?;
279                            buffer.push(value);
280                            if batch_size != 0 && buffer.len() >= chunk {
281                                let page = std::mem::replace(
282                                    &mut buffer,
283                                    Vec::with_capacity(initial_capacity),
284                                );
285                                total += page.len();
286                                yield StreamPage { records: page, bookmark: None };
287                            }
288                        }
289                        if batch_size == 0 && !buffer.is_empty() {
290                            let page = std::mem::take(&mut buffer);
291                            total += page.len();
292                            yield StreamPage { records: page, bookmark: None };
293                        }
294                    }
295                    GcsFileFormat::RawText => {
296                        let text = self.read_object_text(key).await?;
297                        let record = serde_json::json!({ "key": key, "content": text });
298                        buffer.push(record);
299                        if batch_size == 0 {
300                            let page = std::mem::take(&mut buffer);
301                            total += page.len();
302                            yield StreamPage { records: page, bookmark: None };
303                        } else if buffer.len() >= chunk {
304                            let page = std::mem::replace(
305                                &mut buffer,
306                                Vec::with_capacity(initial_capacity),
307                            );
308                            total += page.len();
309                            yield StreamPage { records: page, bookmark: None };
310                        }
311                    }
312                    GcsFileFormat::JsonArray => {
313                        let text = self.read_object_text(key).await?;
314                        let value: Value = serde_json::from_str(&text).map_err(|e| {
315                            FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
316                        })?;
317                        let array = match value {
318                            Value::Array(arr) => arr,
319                            other => Err(FaucetError::Source(format!(
320                                "GCS expected JSON array in '{key}', got {}",
321                                value_type_name(&other)
322                            )))?,
323                        };
324                        if batch_size == 0 {
325                            if !buffer.is_empty() {
326                                let page = std::mem::take(&mut buffer);
327                                total += page.len();
328                                yield StreamPage { records: page, bookmark: None };
329                            }
330                            total += array.len();
331                            yield StreamPage { records: array, bookmark: None };
332                        } else {
333                            for record in array {
334                                buffer.push(record);
335                                if buffer.len() >= chunk {
336                                    let page = std::mem::replace(
337                                        &mut buffer,
338                                        Vec::with_capacity(initial_capacity),
339                                    );
340                                    total += page.len();
341                                    yield StreamPage { records: page, bookmark: None };
342                                }
343                            }
344                        }
345                    }
346                }
347            }
348
349            if !buffer.is_empty() {
350                let page = std::mem::take(&mut buffer);
351                total += page.len();
352                yield StreamPage { records: page, bookmark: None };
353            }
354
355            tracing::info!(
356                total_records = total,
357                batch_size,
358                objects = keys.len(),
359                "GCS source stream complete",
360            );
361        })
362    }
363
364    fn config_schema(&self) -> Value {
365        serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
366            .expect("schema serialization")
367    }
368
369    fn connector_name(&self) -> &'static str {
370        "gcs"
371    }
372
373    fn dataset_uri(&self) -> String {
374        match &self.config.prefix {
375            Some(p) => format!("gs://{}/{}", self.config.bucket, p),
376            None => format!("gs://{}", self.config.bucket),
377        }
378    }
379}
380
381/// Truncate an explicit object-key list to the `max_objects` cap.
382///
383/// `None` leaves the list untouched; `Some(n)` keeps at most the first `n`
384/// keys. This mirrors the cap the listing path applies while paginating, so
385/// `max_objects` is honoured whether keys come from `object_keys` or a live
386/// `list_objects` scan.
387fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
388    if let Some(n) = max {
389        keys.truncate(n);
390    }
391    keys
392}
393
394fn value_type_name(v: &Value) -> &'static str {
395    match v {
396        Value::Null => "null",
397        Value::Bool(_) => "boolean",
398        Value::Number(_) => "number",
399        Value::String(_) => "string",
400        Value::Array(_) => "array",
401        Value::Object(_) => "object",
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use serde_json::json;
409
410    #[cfg(feature = "compression")]
411    #[test]
412    fn compression_default_is_auto() {
413        let cfg = GcsSourceConfig::new("bucket");
414        assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
415    }
416
417    #[test]
418    fn value_type_name_covers_all_json_variants() {
419        assert_eq!(value_type_name(&Value::Null), "null");
420        assert_eq!(value_type_name(&json!(true)), "boolean");
421        assert_eq!(value_type_name(&json!(7)), "number");
422        assert_eq!(value_type_name(&json!("s")), "string");
423        assert_eq!(value_type_name(&json!([1, 2])), "array");
424        assert_eq!(value_type_name(&json!({"k": 1})), "object");
425    }
426
427    #[test]
428    fn parse_json_lines() {
429        let r =
430            parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
431        assert_eq!(r.len(), 2);
432        assert_eq!(r[0]["id"], 1);
433    }
434
435    #[test]
436    fn parse_json_lines_skips_blanks() {
437        let r = parse_file_content(
438            &GcsFileFormat::JsonLines,
439            "t",
440            "{\"id\":1}\n\n{\"id\":2}\n\n",
441        )
442        .unwrap();
443        assert_eq!(r.len(), 2);
444    }
445
446    #[test]
447    fn parse_json_lines_reports_line_number() {
448        let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
449            .unwrap_err();
450        let msg = err.to_string();
451        assert!(msg.contains("line 2"), "unexpected: {msg}");
452    }
453
454    #[test]
455    fn parse_json_array() {
456        let r = parse_file_content(
457            &GcsFileFormat::JsonArray,
458            "t.json",
459            "[{\"id\":1},{\"id\":2}]",
460        )
461        .unwrap();
462        assert_eq!(r.len(), 2);
463    }
464
465    #[test]
466    fn parse_json_array_rejects_non_array() {
467        let err =
468            parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
469        assert!(err.to_string().contains("expected JSON array"));
470    }
471
472    #[test]
473    fn parse_raw_text_yields_single_record() {
474        let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
475        assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
476    }
477
478    #[test]
479    fn cap_keys_truncates_explicit_list_to_max_objects() {
480        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
481        let capped = cap_keys(keys, Some(2));
482        assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
483    }
484
485    #[test]
486    fn cap_keys_passes_through_when_no_max() {
487        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
488        let capped = cap_keys(keys.clone(), None);
489        assert_eq!(capped, keys);
490    }
491
492    #[test]
493    fn cap_keys_noop_when_max_exceeds_len() {
494        let keys = vec!["a".to_string(), "b".to_string()];
495        let capped = cap_keys(keys.clone(), Some(10));
496        assert_eq!(capped, keys);
497    }
498
499    // GcsSource requires an async constructor that tries to connect to GCS,
500    // so we verify the dataset_uri() logic directly via the config fields.
501    #[test]
502    fn dataset_uri_no_prefix_logic() {
503        let config = GcsSourceConfig::new("my-bucket");
504        let uri = match &config.prefix {
505            Some(p) => format!("gs://{}/{}", config.bucket, p),
506            None => format!("gs://{}", config.bucket),
507        };
508        assert_eq!(uri, "gs://my-bucket");
509    }
510
511    #[test]
512    fn dataset_uri_with_prefix_logic() {
513        let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
514        let uri = match &config.prefix {
515            Some(p) => format!("gs://{}/{}", config.bucket, p),
516            None => format!("gs://{}", config.bucket),
517        };
518        assert_eq!(uri, "gs://my-bucket/data/2026/");
519    }
520}