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    fn supports_discover(&self) -> bool {
463        true
464    }
465
466    /// Enumerate the "directories" directly under the configured prefix via
467    /// **one** delimiter (`/`) listing page — each common prefix becomes a
468    /// `prefix` dataset. When the listing returns no common prefixes but does
469    /// return objects directly under the prefix, each object (first page
470    /// only, ≤ `DISCOVER_MAX_OBJECTS`) becomes an `object` dataset instead,
471    /// selected via the exact-match `object_keys` config field. No recursion
472    /// and no data scan — object counts would require paging the whole
473    /// listing, so `estimated_rows` is never set.
474    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
499/// Cap on object-fallback descriptors — one delimiter-listing page, matching
500/// the `page_size` requested from GCS.
501const DISCOVER_MAX_OBJECTS: usize = 1000;
502
503/// Build one [`DatasetDescriptor`](faucet_core::DatasetDescriptor) per common
504/// prefix from a single delimiter listing; when the listing yielded no common
505/// prefixes, fall back to one descriptor per object (capped at
506/// `DISCOVER_MAX_OBJECTS`). Prefix datasets patch the source's `prefix`
507/// config field; object datasets patch `object_keys` (which selects exactly
508/// that object and makes `prefix` inert). Pure — unit-testable without a GCS
509/// client.
510fn 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
534/// Truncate an explicit object-key list to the `max_objects` cap.
535///
536/// `None` leaves the list untouched; `Some(n)` keeps at most the first `n`
537/// keys. This mirrors the cap the listing path applies while paginating, so
538/// `max_objects` is honoured whether keys come from `object_keys` or a live
539/// `list_objects` scan.
540fn 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
547/// Retain only the keys owned by `shard` (hash-of-key modulo `shards`). Free
548/// function (vs. a `GcsSource` method) so the partitioning logic is
549/// unit-testable without a GCS client — constructing the source requires live
550/// credentials, and the gRPC integration tests are `#[ignore]`d (#220).
551fn 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    // ── Hash-modulo sharding (Mode B, #262) ──────────────────────────────────
664
665    // The union of every shard's filtered key set equals the full set, with no
666    // key in two shards — the core no-dup / no-loss guarantee.
667    #[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    // ── discover: pure listing → descriptor mapping ─────────────────────────
694
695    #[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    // Prefixes win: objects sitting alongside common prefixes are not
712    // enumerated as datasets (they'd be a mixed listing at the same level).
713    #[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    // GcsSource requires an async constructor that tries to connect to GCS,
763    // so we verify the dataset_uri() logic directly via the config fields.
764    #[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}