Skip to main content

faucet_sink_gcs/
sink.rs

1//! GCS sink executor.
2
3use crate::config::GcsSinkConfig;
4use async_trait::async_trait;
5use faucet_common_gcs::{build_storage, build_storage_control};
6use faucet_core::FaucetError;
7use futures::stream::{self, StreamExt, TryStreamExt};
8use google_cloud_storage::client::Storage;
9use serde_json::Value;
10
11/// A sink that writes JSON records to GCS as JSON Lines files.
12pub struct GcsSink {
13    config: GcsSinkConfig,
14    storage: Storage,
15}
16
17impl GcsSink {
18    pub async fn new(config: GcsSinkConfig) -> Result<Self, FaucetError> {
19        faucet_core::validate_batch_size(config.batch_size)?;
20        let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
21        Ok(Self { config, storage })
22    }
23
24    /// Bucket as a GCS resource path: `projects/_/buckets/{bucket}`.
25    fn bucket_path(&self) -> String {
26        format!("projects/_/buckets/{}", self.config.bucket)
27    }
28
29    /// Serialize a slice of records as a JSON Lines byte buffer.
30    fn serialize_jsonl(records: &[Value]) -> Result<Vec<u8>, FaucetError> {
31        let mut buf: Vec<u8> = Vec::new();
32        for record in records {
33            let line = serde_json::to_vec(record)
34                .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
35            buf.extend_from_slice(&line);
36            buf.push(b'\n');
37        }
38        Ok(buf)
39    }
40
41    /// Generate a time-sortable UUIDv7 object name.
42    fn generate_key(&self) -> String {
43        generate_object_key(&self.config.prefix, &self.config.file_extension)
44    }
45
46    /// Upload a single JSONL file to GCS.
47    async fn upload_file(&self, key: &str, body: Vec<u8>) -> Result<(), FaucetError> {
48        #[cfg(feature = "compression")]
49        let body = {
50            let codec = self.config.compression.resolve(&self.config.file_extension);
51            faucet_core::compression::warn_mismatch(&self.config.file_extension, codec);
52            faucet_core::compression::compress_buf(&body, codec)?
53        };
54
55        let payload = bytes::Bytes::from(body);
56        self.storage
57            .write_object(self.bucket_path(), key.to_string(), payload)
58            .set_content_type("application/x-ndjson")
59            .send_unbuffered()
60            .await
61            .map_err(|e| FaucetError::Sink(format!("GCS put object error for key '{key}': {e}")))?;
62        tracing::debug!(key = %key, "Uploaded GCS object");
63        Ok(())
64    }
65
66    /// Compute the effective chunk size combining `batch_size` and
67    /// `max_records_per_file`. `batch_size = 0` removes the batch-size
68    /// limit; `max_records_per_file = None` removes the file-rollover
69    /// limit. When both are unlimited, returns `usize::MAX` (single chunk).
70    fn effective_chunk_size(&self) -> usize {
71        resolve_effective_chunk_size(&self.config)
72    }
73}
74
75#[async_trait]
76impl faucet_core::Sink for GcsSink {
77    fn dataset_uri(&self) -> String {
78        format!("gs://{}/{}", self.config.bucket, self.config.prefix)
79    }
80
81    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
82        if records.is_empty() {
83            return Ok(0);
84        }
85        let chunk = self.effective_chunk_size();
86        let concurrency = self.config.concurrency.max(1);
87
88        let uploads: Vec<(String, Vec<u8>)> = records
89            .chunks(chunk)
90            .map(|slice| {
91                let body = Self::serialize_jsonl(slice)?;
92                Ok::<(String, Vec<u8>), FaucetError>((self.generate_key(), body))
93            })
94            .collect::<Result<_, _>>()?;
95
96        let written = records.len();
97        stream::iter(uploads)
98            .map(|(key, body)| async move { self.upload_file(&key, body).await })
99            .buffer_unordered(concurrency)
100            .try_collect::<Vec<()>>()
101            .await?;
102
103        Ok(written)
104    }
105
106    fn config_schema(&self) -> Value {
107        serde_json::to_value(faucet_core::schema_for!(GcsSinkConfig)).expect("schema serialization")
108    }
109
110    fn connector_name(&self) -> &'static str {
111        "gcs"
112    }
113
114    /// Preflight probe: confirm the configured bucket is reachable and the
115    /// credentials work via a non-mutating `list_objects` call capped at a
116    /// single result. Writes nothing.
117    ///
118    /// The sink only holds a data-plane [`Storage`] client (which exposes no
119    /// list/get-bucket call), so the probe builds a control-plane
120    /// `StorageControl` client on demand using the same credentials.
121    async fn check(
122        &self,
123        ctx: &faucet_core::check::CheckContext,
124    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
125        use faucet_core::check::{CheckReport, Probe};
126
127        let started = std::time::Instant::now();
128
129        // Build a control-plane client (the data-plane Storage client has no
130        // read-only list/get-bucket call). Credential/client-build failures
131        // surface as a failed probe rather than an Err.
132        let control =
133            match build_storage_control(&self.config.auth, self.config.storage_host.as_deref())
134                .await
135            {
136                Ok(c) => c,
137                Err(e) => {
138                    return Ok(CheckReport::single(Probe::fail_hint(
139                        "auth",
140                        started.elapsed(),
141                        e.to_string(),
142                        "check bucket name, credentials, and network",
143                    )));
144                }
145            };
146
147        let probe = match tokio::time::timeout(
148            ctx.timeout,
149            control
150                .list_objects()
151                .set_parent(self.bucket_path())
152                .set_page_size(1_i32)
153                .send(),
154        )
155        .await
156        {
157            Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
158            Ok(Err(e)) => Probe::fail_hint(
159                "auth",
160                started.elapsed(),
161                e.to_string(),
162                "check bucket name, credentials, and network",
163            ),
164            Err(_) => Probe::fail("network", started.elapsed(), "timed out"),
165        };
166        Ok(CheckReport::single(probe))
167    }
168}
169
170/// Pure helper for chunk-size resolution — used by `write_batch` and unit
171/// tested directly so the test surface doesn't need a `Storage` stub.
172fn resolve_effective_chunk_size(config: &GcsSinkConfig) -> usize {
173    let bs = if config.batch_size == 0 {
174        usize::MAX
175    } else {
176        config.batch_size
177    };
178    let mr = config.max_records_per_file.unwrap_or(usize::MAX);
179    bs.min(mr)
180}
181
182/// Pure helper for object-key generation — used by `write_batch` and unit
183/// tested directly. UUIDv7 makes keys time-sortable so a listing of the
184/// destination bucket returns objects in write order.
185fn generate_object_key(prefix: &str, file_extension: &str) -> String {
186    format!("{prefix}{}{file_extension}", uuid::Uuid::now_v7())
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // dataset_uri test is skipped: GcsSink::new() requires Google Cloud
194    // credentials (build_storage errors without auth), and no offline
195    // constructor exists.
196
197    #[tokio::test]
198    async fn new_rejects_out_of_range_batch_size() {
199        // Validation runs before any GCS client setup, so this needs no backend.
200        let mut config = GcsSinkConfig::new("bucket");
201        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
202        match GcsSink::new(config).await {
203            Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
204            _ => panic!("expected a batch_size Config error"),
205        }
206    }
207    use serde_json::json;
208
209    #[test]
210    fn serialize_jsonl_two_records() {
211        let body = GcsSink::serialize_jsonl(&[json!({"a": 1}), json!({"b": 2})]).unwrap();
212        assert_eq!(
213            std::str::from_utf8(&body).unwrap(),
214            "{\"a\":1}\n{\"b\":2}\n"
215        );
216    }
217
218    #[test]
219    fn serialize_jsonl_empty_is_empty() {
220        let body = GcsSink::serialize_jsonl(&[]).unwrap();
221        assert!(body.is_empty());
222    }
223
224    #[test]
225    fn effective_chunk_size_unlimited_when_both_unset() {
226        let cfg = GcsSinkConfig::new("b").with_batch_size(0);
227        assert_eq!(resolve_effective_chunk_size(&cfg), usize::MAX);
228    }
229
230    #[test]
231    fn effective_chunk_size_takes_smaller_limit() {
232        let cfg = GcsSinkConfig::new("b")
233            .with_batch_size(500)
234            .max_records_per_file(100);
235        assert_eq!(resolve_effective_chunk_size(&cfg), 100);
236    }
237
238    #[test]
239    fn effective_chunk_size_uses_batch_size_when_smaller() {
240        let cfg = GcsSinkConfig::new("b")
241            .with_batch_size(50)
242            .max_records_per_file(500);
243        assert_eq!(resolve_effective_chunk_size(&cfg), 50);
244    }
245
246    #[test]
247    fn generate_key_uses_prefix_and_extension() {
248        let key = generate_object_key("out/", ".ndjson");
249        assert!(key.starts_with("out/"));
250        assert!(key.ends_with(".ndjson"));
251    }
252
253    #[test]
254    fn generate_key_yields_distinct_time_ordered_keys() {
255        let a = generate_object_key("p/", ".jsonl");
256        let b = generate_object_key("p/", ".jsonl");
257        assert_ne!(a, b);
258        // UUIDv7 keys are lexically comparable by time within the same
259        // process: the second key generated should compare greater.
260        assert!(a < b, "expected UUIDv7 keys to sort by generation order");
261    }
262
263    #[cfg(feature = "compression")]
264    #[test]
265    fn compress_buf_used_for_zstd_extension() {
266        let cfg = GcsSinkConfig::new("bucket").file_extension(".jsonl.zst");
267        let codec = cfg.compression.resolve(&cfg.file_extension);
268        assert_eq!(codec, faucet_core::Compression::Zstd);
269        let compressed = faucet_core::compression::compress_buf(b"hello\n", codec).unwrap();
270        // zstd magic bytes: 0x28 B5 2F FD.
271        assert_eq!(&compressed[..4], b"\x28\xb5\x2f\xfd");
272    }
273}