1use 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
16pub struct GcsSource {
18 config: GcsSourceConfig,
19 storage: Storage,
20 control: StorageControl,
21 applied_shard: Mutex<Option<HashShard>>,
25}
26
27impl GcsSource {
28 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 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 fn bucket_path(&self) -> String {
52 format!("projects/_/buckets/{}", self.config.bucket)
53 }
54
55 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 Ok(self.shard_filter(names))
95 }
96
97 async fn read_object_text(&self, key: &str) -> Result<String, FaucetError> {
99 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 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 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 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 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
197pub(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 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 fn is_shardable(&self) -> bool {
444 true
445 }
446
447 async fn enumerate_shards(&self, target: usize) -> Result<Vec<ShardSpec>, FaucetError> {
452 Ok(plan_hash_shards(target))
453 }
454
455 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 fn supports_discover(&self) -> bool {
463 true
464 }
465
466 async fn discover(&self) -> Result<Vec<faucet_core::DatasetDescriptor>, FaucetError> {
475 let mut req = self
476 .control
477 .list_objects()
478 .set_parent(self.bucket_path())
479 .set_delimiter("/")
480 .set_page_size(DISCOVER_MAX_OBJECTS as i32);
481 if let Some(p) = self.config.prefix.as_deref() {
482 req = req.set_prefix(p.to_string());
483 }
484 let response = req
485 .send()
486 .await
487 .map_err(|e| FaucetError::Source(format!("gcs: catalog discovery failed: {e}")))?;
488
489 let objects: Vec<String> = response
490 .objects
491 .into_iter()
492 .map(|o| o.name)
493 .filter(|n| !n.is_empty())
494 .collect();
495 Ok(descriptors_from_listing(response.prefixes, objects))
496 }
497}
498
499const DISCOVER_MAX_OBJECTS: usize = 1000;
502
503fn descriptors_from_listing(
511 prefixes: Vec<String>,
512 objects: Vec<String>,
513) -> Vec<faucet_core::DatasetDescriptor> {
514 let prefixes: Vec<String> = prefixes.into_iter().filter(|p| !p.is_empty()).collect();
515 if !prefixes.is_empty() {
516 return prefixes
517 .into_iter()
518 .map(|p| {
519 let patch = serde_json::json!({ "prefix": p });
520 faucet_core::DatasetDescriptor::new(p, "prefix", patch)
521 })
522 .collect();
523 }
524 objects
525 .into_iter()
526 .take(DISCOVER_MAX_OBJECTS)
527 .map(|k| {
528 let patch = serde_json::json!({ "object_keys": [k] });
529 faucet_core::DatasetDescriptor::new(k, "object", patch)
530 })
531 .collect()
532}
533
534fn cap_keys(mut keys: Vec<String>, max: Option<usize>) -> Vec<String> {
541 if let Some(n) = max {
542 keys.truncate(n);
543 }
544 keys
545}
546
547fn filter_shard_keys(keys: Vec<String>, shard: Option<HashShard>) -> Vec<String> {
552 match shard {
553 Some(member) => keys.into_iter().filter(|k| member.contains(k)).collect(),
554 None => keys,
555 }
556}
557
558fn value_type_name(v: &Value) -> &'static str {
559 match v {
560 Value::Null => "null",
561 Value::Bool(_) => "boolean",
562 Value::Number(_) => "number",
563 Value::String(_) => "string",
564 Value::Array(_) => "array",
565 Value::Object(_) => "object",
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use serde_json::json;
573
574 #[cfg(feature = "compression")]
575 #[test]
576 fn compression_default_is_auto() {
577 let cfg = GcsSourceConfig::new("bucket");
578 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
579 }
580
581 #[test]
582 fn value_type_name_covers_all_json_variants() {
583 assert_eq!(value_type_name(&Value::Null), "null");
584 assert_eq!(value_type_name(&json!(true)), "boolean");
585 assert_eq!(value_type_name(&json!(7)), "number");
586 assert_eq!(value_type_name(&json!("s")), "string");
587 assert_eq!(value_type_name(&json!([1, 2])), "array");
588 assert_eq!(value_type_name(&json!({"k": 1})), "object");
589 }
590
591 #[test]
592 fn parse_json_lines() {
593 let r =
594 parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\n{\"id\":2}\n").unwrap();
595 assert_eq!(r.len(), 2);
596 assert_eq!(r[0]["id"], 1);
597 }
598
599 #[test]
600 fn parse_json_lines_skips_blanks() {
601 let r = parse_file_content(
602 &GcsFileFormat::JsonLines,
603 "t",
604 "{\"id\":1}\n\n{\"id\":2}\n\n",
605 )
606 .unwrap();
607 assert_eq!(r.len(), 2);
608 }
609
610 #[test]
611 fn parse_json_lines_reports_line_number() {
612 let err = parse_file_content(&GcsFileFormat::JsonLines, "t", "{\"id\":1}\nbad-line\n")
613 .unwrap_err();
614 let msg = err.to_string();
615 assert!(msg.contains("line 2"), "unexpected: {msg}");
616 }
617
618 #[test]
619 fn parse_json_array() {
620 let r = parse_file_content(
621 &GcsFileFormat::JsonArray,
622 "t.json",
623 "[{\"id\":1},{\"id\":2}]",
624 )
625 .unwrap();
626 assert_eq!(r.len(), 2);
627 }
628
629 #[test]
630 fn parse_json_array_rejects_non_array() {
631 let err =
632 parse_file_content(&GcsFileFormat::JsonArray, "t.json", "{\"id\":1}").unwrap_err();
633 assert!(err.to_string().contains("expected JSON array"));
634 }
635
636 #[test]
637 fn parse_raw_text_yields_single_record() {
638 let r = parse_file_content(&GcsFileFormat::RawText, "p/f.txt", "hello").unwrap();
639 assert_eq!(r, vec![json!({"key": "p/f.txt", "content": "hello"})]);
640 }
641
642 #[test]
643 fn cap_keys_truncates_explicit_list_to_max_objects() {
644 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
645 let capped = cap_keys(keys, Some(2));
646 assert_eq!(capped, vec!["a".to_string(), "b".to_string()]);
647 }
648
649 #[test]
650 fn cap_keys_passes_through_when_no_max() {
651 let keys = vec!["a".to_string(), "b".to_string(), "c".to_string()];
652 let capped = cap_keys(keys.clone(), None);
653 assert_eq!(capped, keys);
654 }
655
656 #[test]
657 fn cap_keys_noop_when_max_exceeds_len() {
658 let keys = vec!["a".to_string(), "b".to_string()];
659 let capped = cap_keys(keys.clone(), Some(10));
660 assert_eq!(capped, keys);
661 }
662
663 #[test]
668 fn shard_filter_partitions_keys_disjointly_and_completely() {
669 let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
670 let members: Vec<HashShard> = plan_hash_shards(4)
671 .iter()
672 .map(|s| HashShard::from_spec(s).expect("descriptor parses"))
673 .collect();
674 let mut union: Vec<String> = Vec::new();
675 for member in members {
676 union.extend(filter_shard_keys(keys.clone(), Some(member)));
677 }
678 union.sort();
679 let mut expected = keys.clone();
680 expected.sort();
681 assert_eq!(
682 union, expected,
683 "shards must union to the full key set, disjointly"
684 );
685 }
686
687 #[test]
688 fn no_applied_shard_reads_everything() {
689 let keys: Vec<String> = (0..20).map(|i| format!("k{i}")).collect();
690 assert_eq!(filter_shard_keys(keys.clone(), None), keys);
691 }
692
693 #[test]
696 fn descriptors_from_listing_maps_common_prefixes() {
697 let out = descriptors_from_listing(
698 vec!["raw/orders/".to_string(), "raw/users/".to_string()],
699 vec![],
700 );
701 assert_eq!(out.len(), 2);
702 assert_eq!(out[0].name, "raw/orders/");
703 assert_eq!(out[0].kind, "prefix");
704 assert_eq!(out[0].config_patch, json!({ "prefix": "raw/orders/" }));
705 assert!(out[0].schema.is_none());
706 assert!(out[0].estimated_rows.is_none());
707 assert_eq!(out[1].name, "raw/users/");
708 assert_eq!(out[1].config_patch, json!({ "prefix": "raw/users/" }));
709 }
710
711 #[test]
714 fn descriptors_from_listing_prefers_prefixes_over_objects() {
715 let out = descriptors_from_listing(
716 vec!["raw/orders/".to_string()],
717 vec!["raw/readme.txt".to_string()],
718 );
719 assert_eq!(out.len(), 1);
720 assert_eq!(out[0].kind, "prefix");
721 assert_eq!(out[0].name, "raw/orders/");
722 }
723
724 #[test]
725 fn descriptors_from_listing_falls_back_to_objects_via_object_keys() {
726 let out = descriptors_from_listing(
727 vec![],
728 vec!["raw/a.jsonl".to_string(), "raw/b.jsonl".to_string()],
729 );
730 assert_eq!(out.len(), 2);
731 assert_eq!(out[0].name, "raw/a.jsonl");
732 assert_eq!(out[0].kind, "object");
733 assert_eq!(
734 out[0].config_patch,
735 json!({ "object_keys": ["raw/a.jsonl"] })
736 );
737 assert!(out[0].schema.is_none());
738 assert!(out[0].estimated_rows.is_none());
739 }
740
741 #[test]
742 fn descriptors_from_listing_empty_listing_yields_no_datasets() {
743 assert!(descriptors_from_listing(vec![], vec![]).is_empty());
744 }
745
746 #[test]
747 fn descriptors_from_listing_skips_empty_prefixes() {
748 let out = descriptors_from_listing(vec![String::new(), "raw/orders/".to_string()], vec![]);
749 assert_eq!(out.len(), 1);
750 assert_eq!(out[0].name, "raw/orders/");
751 }
752
753 #[test]
754 fn descriptors_from_listing_caps_object_fallback() {
755 let objects: Vec<String> = (0..DISCOVER_MAX_OBJECTS + 500)
756 .map(|i| format!("obj-{i}.jsonl"))
757 .collect();
758 let out = descriptors_from_listing(vec![], objects);
759 assert_eq!(out.len(), DISCOVER_MAX_OBJECTS);
760 }
761
762 #[test]
765 fn dataset_uri_no_prefix_logic() {
766 let config = GcsSourceConfig::new("my-bucket");
767 let uri = match &config.prefix {
768 Some(p) => format!("gs://{}/{}", config.bucket, p),
769 None => format!("gs://{}", config.bucket),
770 };
771 assert_eq!(uri, "gs://my-bucket");
772 }
773
774 #[test]
775 fn dataset_uri_with_prefix_logic() {
776 let config = GcsSourceConfig::new("my-bucket").prefix("data/2026/");
777 let uri = match &config.prefix {
778 Some(p) => format!("gs://{}/{}", config.bucket, p),
779 None => format!("gs://{}", config.bucket),
780 };
781 assert_eq!(uri, "gs://my-bucket/data/2026/");
782 }
783}