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
197/// Parse file content into records for a given format. Free function (vs. a
198/// `GcsSource` method) so it is unit-testable without a GCS client — the
199/// parsing logic is pure. Previously this logic lived only inside the
200/// `parse_content` method and was duplicated by a copy in the test module;
201/// that copy could silently drift from production since the integration tests
202/// that would have caught it are `#[ignore]`d (no gRPC emulator exists).
203pub(crate) fn parse_file_content(
204    format: &GcsFileFormat,
205    key: &str,
206    text: &str,
207) -> Result<Vec<Value>, FaucetError> {
208    match format {
209        GcsFileFormat::JsonLines => {
210            let mut records = Vec::new();
211            for (line_num, line) in text.lines().enumerate() {
212                let trimmed = line.trim();
213                if trimmed.is_empty() {
214                    continue;
215                }
216                let value: Value = serde_json::from_str(trimmed).map_err(|e| {
217                    FaucetError::Source(format!(
218                        "GCS JSON parse error in '{key}' at line {}: {e}",
219                        line_num + 1
220                    ))
221                })?;
222                records.push(value);
223            }
224            Ok(records)
225        }
226        GcsFileFormat::JsonArray => {
227            let value: Value = serde_json::from_str(text).map_err(|e| {
228                FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
229            })?;
230            match value {
231                Value::Array(arr) => Ok(arr),
232                other => Err(FaucetError::Source(format!(
233                    "GCS expected JSON array in '{key}', got {}",
234                    value_type_name(&other)
235                ))),
236            }
237        }
238        GcsFileFormat::RawText => Ok(vec![serde_json::json!({
239            "key": key,
240            "content": text,
241        })]),
242    }
243}
244
245#[async_trait]
246impl faucet_core::Source for GcsSource {
247    async fn fetch_with_context(
248        &self,
249        context: &std::collections::HashMap<String, Value>,
250    ) -> Result<Vec<Value>, FaucetError> {
251        let substituted_prefix: Option<String> = if !context.is_empty() {
252            self.config
253                .prefix
254                .as_ref()
255                .map(|p| faucet_core::util::substitute_context(p, context))
256        } else {
257            None
258        };
259
260        let keys = self
261            .list_object_names(substituted_prefix.as_deref())
262            .await?;
263        tracing::info!(
264            bucket = %self.config.bucket,
265            objects = keys.len(),
266            "Listed GCS objects",
267        );
268
269        let concurrency = self.config.concurrency.max(1);
270        let results: Vec<Vec<Value>> = stream::iter(keys)
271            .map(|key| async move {
272                let text = self.read_object_text(&key).await?;
273                let records = self.parse_content(&key, &text)?;
274                tracing::debug!(key = %key, records = records.len(), "Read GCS object");
275                Ok::<Vec<Value>, FaucetError>(records)
276            })
277            .buffer_unordered(concurrency)
278            .try_collect()
279            .await?;
280
281        let all_records: Vec<Value> = results.into_iter().flatten().collect();
282        tracing::info!(total_records = all_records.len(), "GCS fetch complete");
283        Ok(all_records)
284    }
285
286    /// Stream records from listed GCS objects without buffering the full
287    /// scan. Mirrors `S3Source::stream_pages` — see that implementation
288    /// for the per-format reasoning.
289    fn stream_pages<'a>(
290        &'a self,
291        context: &'a std::collections::HashMap<String, Value>,
292        _batch_size: usize,
293    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
294        let batch_size = self.config.batch_size;
295
296        Box::pin(async_stream::try_stream! {
297            let substituted_prefix: Option<String> = if !context.is_empty() {
298                self.config
299                    .prefix
300                    .as_ref()
301                    .map(|p| faucet_core::util::substitute_context(p, context))
302            } else {
303                None
304            };
305
306            let keys = self.list_object_names(substituted_prefix.as_deref()).await?;
307            tracing::info!(
308                bucket = %self.config.bucket,
309                objects = keys.len(),
310                "Listed GCS objects (stream)",
311            );
312
313            let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
314            let initial_capacity = if batch_size == 0 { 1024 } else { batch_size };
315            let mut buffer: Vec<Value> = Vec::with_capacity(initial_capacity);
316            let mut total = 0usize;
317
318            for key in &keys {
319                match self.config.file_format {
320                    GcsFileFormat::JsonLines => {
321                        let reader = self.open_object_reader(key).await?;
322                        let mut lines = reader.lines();
323                        let mut line_num: usize = 0;
324                        while let Some(line) = lines
325                            .next_line()
326                            .await
327                            .map_err(|e| FaucetError::Source(format!(
328                                "GCS read body error for key '{key}': {e}"
329                            )))?
330                        {
331                            line_num += 1;
332                            let trimmed = line.trim();
333                            if trimmed.is_empty() { continue; }
334                            let value: Value = serde_json::from_str(trimmed).map_err(|e| {
335                                FaucetError::Source(format!(
336                                    "GCS JSON parse error in '{key}' at line {line_num}: {e}",
337                                ))
338                            })?;
339                            buffer.push(value);
340                            if batch_size != 0 && buffer.len() >= chunk {
341                                let page = std::mem::replace(
342                                    &mut buffer,
343                                    Vec::with_capacity(initial_capacity),
344                                );
345                                total += page.len();
346                                yield StreamPage { records: page, bookmark: None };
347                            }
348                        }
349                        if batch_size == 0 && !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                    GcsFileFormat::RawText => {
356                        let text = self.read_object_text(key).await?;
357                        let record = serde_json::json!({ "key": key, "content": text });
358                        buffer.push(record);
359                        if batch_size == 0 {
360                            let page = std::mem::take(&mut buffer);
361                            total += page.len();
362                            yield StreamPage { records: page, bookmark: None };
363                        } else if buffer.len() >= chunk {
364                            let page = std::mem::replace(
365                                &mut buffer,
366                                Vec::with_capacity(initial_capacity),
367                            );
368                            total += page.len();
369                            yield StreamPage { records: page, bookmark: None };
370                        }
371                    }
372                    GcsFileFormat::JsonArray => {
373                        let text = self.read_object_text(key).await?;
374                        let value: Value = serde_json::from_str(&text).map_err(|e| {
375                            FaucetError::Source(format!("GCS JSON parse error in '{key}': {e}"))
376                        })?;
377                        let array = match value {
378                            Value::Array(arr) => arr,
379                            other => Err(FaucetError::Source(format!(
380                                "GCS expected JSON array in '{key}', got {}",
381                                value_type_name(&other)
382                            )))?,
383                        };
384                        if batch_size == 0 {
385                            if !buffer.is_empty() {
386                                let page = std::mem::take(&mut buffer);
387                                total += page.len();
388                                yield StreamPage { records: page, bookmark: None };
389                            }
390                            total += array.len();
391                            yield StreamPage { records: array, bookmark: None };
392                        } else {
393                            for record in array {
394                                buffer.push(record);
395                                if buffer.len() >= chunk {
396                                    let page = std::mem::replace(
397                                        &mut buffer,
398                                        Vec::with_capacity(initial_capacity),
399                                    );
400                                    total += page.len();
401                                    yield StreamPage { records: page, bookmark: None };
402                                }
403                            }
404                        }
405                    }
406                }
407            }
408
409            if !buffer.is_empty() {
410                let page = std::mem::take(&mut buffer);
411                total += page.len();
412                yield StreamPage { records: page, bookmark: None };
413            }
414
415            tracing::info!(
416                total_records = total,
417                batch_size,
418                objects = keys.len(),
419                "GCS source stream complete",
420            );
421        })
422    }
423
424    fn config_schema(&self) -> Value {
425        serde_json::to_value(faucet_core::schema_for!(GcsSourceConfig))
426            .expect("schema serialization")
427    }
428
429    fn connector_name(&self) -> &'static str {
430        "gcs"
431    }
432
433    fn dataset_uri(&self) -> String {
434        match &self.config.prefix {
435            Some(p) => format!("gs://{}/{}", self.config.bucket, p),
436            None => format!("gs://{}", self.config.bucket),
437        }
438    }
439
440    /// The GCS source is always shardable: any object set can be split by
441    /// hash-of-key. Sharding only takes effect when the cluster coordinator
442    /// calls `apply_shard`; a plain `faucet run` reads every object.
443    fn is_shardable(&self) -> bool {
444        true
445    }
446
447    /// Enumerate `target` hash-modulo shards. Each shard `i` will read the
448    /// objects whose key hashes to `i (mod target)`. No I/O: the partition is
449    /// defined by the hash function, so enumeration is cheap and stable as new
450    /// objects appear. `target <= 1` yields a single whole-dataset shard.
451    async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
452        Ok(plan_hash_shards(target))
453    }
454
455    /// Narrow this source to one hash-modulo shard. The whole-dataset shard
456    /// clears any filter (reads every object).
457    async fn apply_shard(&self, shard: &ShardSpec) -> Result<(), FaucetError> {
458        *self.applied_shard.lock().expect("shard mutex poisoned") = parse_hash_shard(shard, "gcs")?;
459        Ok(())
460    }
461}
462
463/// Truncate an explicit object-key list to the `max_objects` cap.
464///
465/// `None` leaves the list untouched; `Some(n)` keeps at most the first `n`
466/// keys. This mirrors the cap the listing path applies while paginating, so
467/// `max_objects` is honoured whether keys come from `object_keys` or a live
468/// `list_objects` scan.
469fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
470    if let Some(n) = max {
471        keys.truncate(n);
472    }
473    keys
474}
475
476/// Retain only the keys owned by `shard` (hash-of-key modulo `shards`). Free
477/// function (vs. a `GcsSource` method) so the partitioning logic is
478/// unit-testable without a GCS client — constructing the source requires live
479/// credentials, and the gRPC integration tests are `#[ignore]`d (#220).
480fn filter_shard_keys(keys: Vec<String>, shard: Option<HashShard>) -> Vec<String> {
481    match shard {
482        Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
483        None => keys,
484    }
485}
486
487fn value_type_name(v: &Value) -> &'static str {
488    match v {
489        Value::Null => "null",
490        Value::Bool(_) => "boolean",
491        Value::Number(_) => "number",
492        Value::String(_) => "string",
493        Value::Array(_) => "array",
494        Value::Object(_) => "object",
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use serde_json::json;
502
503    #[cfg(feature = "compression")]
504    #[test]
505    fn compression_default_is_auto() {
506        let cfg = GcsSourceConfig::new("bucket");
507        assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
508    }
509
510    #[test]
511    fn value_type_name_covers_all_json_variants() {
512        assert_eq!(value_type_name(&Value::Null), "null");
513        assert_eq!(value_type_name(&json!(true)), "boolean");
514        assert_eq!(value_type_name(&json!(7)), "number");
515        assert_eq!(value_type_name(&json!("s")), "string");
516        assert_eq!(value_type_name(&json!([1, 2])), "array");
517        assert_eq!(value_type_name(&json!({"k": 1})), "object");
518    }
519
520    #[test]
521    fn parse_json_lines() {
522        let r =
523            parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
524        assert_eq!(r.len(), 2);
525        assert_eq!(r[0]["id"], 1);
526    }
527
528    #[test]
529    fn parse_json_lines_skips_blanks() {
530        let r = parse_file_content(
531            &GcsFileFormat::JsonLines,
532            "t",
533            "{\"id\":1}\n\n{\"id\":2}\n\n",
534        )
535        .unwrap();
536        assert_eq!(r.len(), 2);
537    }
538
539    #[test]
540    fn parse_json_lines_reports_line_number() {
541        let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
542            .unwrap_err();
543        let msg = err.to_string();
544        assert!(msg.contains("line 2"), "unexpected: {msg}");
545    }
546
547    #[test]
548    fn parse_json_array() {
549        let r = parse_file_content(
550            &GcsFileFormat::JsonArray,
551            "t.json",
552            "[{\"id\":1},{\"id\":2}]",
553        )
554        .unwrap();
555        assert_eq!(r.len(), 2);
556    }
557
558    #[test]
559    fn parse_json_array_rejects_non_array() {
560        let err =
561            parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
562        assert!(err.to_string().contains("expected JSON array"));
563    }
564
565    #[test]
566    fn parse_raw_text_yields_single_record() {
567        let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
568        assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
569    }
570
571    #[test]
572    fn cap_keys_truncates_explicit_list_to_max_objects() {
573        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
574        let capped = cap_keys(keys, Some(2));
575        assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
576    }
577
578    #[test]
579    fn cap_keys_passes_through_when_no_max() {
580        let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
581        let capped = cap_keys(keys.clone(), None);
582        assert_eq!(capped, keys);
583    }
584
585    #[test]
586    fn cap_keys_noop_when_max_exceeds_len() {
587        let keys = vec!["a".to_string(), "b".to_string()];
588        let capped = cap_keys(keys.clone(), Some(10));
589        assert_eq!(capped, keys);
590    }
591
592    // ── Hash-modulo sharding (Mode B, #262) ──────────────────────────────────
593
594    // The union of every shard's filtered key set equals the full set, with no
595    // key in two shards — the core no-dup / no-loss guarantee.
596    #[test]
597    fn shard_filter_partitions_keys_disjointly_and_completely() {
598        let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
599        let members: Vec<HashShard> = plan_hash_shards(4)
600            .iter()
601            .map(|s| HashShard::from_spec(s).expect("descriptor parses"))
602            .collect();
603        let mut union: Vec<String> = Vec::new();
604        for member in members {
605            union.extend(filter_shard_keys(keys.clone(), Some(member)));
606        }
607        union.sort();
608        let mut expected = keys.clone();
609        expected.sort();
610        assert_eq!(
611            union, expected,
612            "shards must union to the full key set, disjointly"
613        );
614    }
615
616    #[test]
617    fn no_applied_shard_reads_everything() {
618        let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
619        assert_eq!(filter_shard_keys(keys.clone(), None), keys);
620    }
621
622    // GcsSource requires an async constructor that tries to connect to GCS,
623    // so we verify the dataset_uri() logic directly via the config fields.
624    #[test]
625    fn dataset_uri_no_prefix_logic() {
626        let config = GcsSourceConfig::new("my-bucket");
627        let uri = match &config.prefix {
628            Some(p) => format!("gs://{}/{}", config.bucket, p),
629            None => format!("gs://{}", config.bucket),
630        };
631        assert_eq!(uri, "gs://my-bucket");
632    }
633
634    #[test]
635    fn dataset_uri_with_prefix_logic() {
636        let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
637        let uri = match &config.prefix {
638            Some(p) => format!("gs://{}/{}", config.bucket, p),
639            None => format!("gs://{}", config.bucket),
640        };
641        assert_eq!(uri, "gs://my-bucket/data/2026/");
642    }
643}