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
463fn 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
476fn 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 #[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 #[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}