Skip to main content

faucet_sink_gcs/
sink.rs

1//! GCS sink executor.
2
3use crate::config::GcsSinkConfig;
4#[cfg(feature = "arrow")]
5use crate::config::GcsSinkFormat;
6use async_trait::async_trait;
7use faucet_common_gcs::{build_storage, build_storage_control};
8use faucet_core::FaucetError;
9use futures::stream::{self, StreamExt, TryStreamExt};
10use google_cloud_storage::client::Storage;
11use serde_json::Value;
12
13/// A sink that writes JSON records to GCS as JSON Lines files (or, with the
14/// `arrow` feature, self-contained Parquet objects).
15pub struct GcsSink {
16    config: GcsSinkConfig,
17    storage: Storage,
18}
19
20impl GcsSink {
21    pub async fn new(config: GcsSinkConfig) -> Result<Self, FaucetError> {
22        faucet_core::validate_batch_size(config.batch_size)?;
23        let storage = build_storage(&config.auth, config.storage_host.as_deref()).await?;
24        Ok(Self { config, storage })
25    }
26
27    /// Bucket as a GCS resource path: `projects/_/buckets/{bucket}`.
28    fn bucket_path(&self) -> String {
29        format!("projects/_/buckets/{}", self.config.bucket)
30    }
31
32    /// Serialize a slice of records as a JSON Lines byte buffer.
33    fn serialize_jsonl(records: &[Value]) -> Result<Vec<u8>, FaucetError> {
34        let mut buf: Vec<u8> = Vec::new();
35        for record in records {
36            let line = serde_json::to_vec(record)
37                .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
38            buf.extend_from_slice(&line);
39            buf.push(b'\n');
40        }
41        Ok(buf)
42    }
43
44    /// Generate a time-sortable UUIDv7 object name.
45    fn generate_key(&self) -> String {
46        generate_object_key(&self.config.prefix, &self.config.file_extension)
47    }
48
49    /// Upload a single JSONL file to GCS.
50    async fn upload_file(&self, key: &str, body: Vec<u8>) -> Result<(), FaucetError> {
51        #[cfg(feature = "compression")]
52        let body = {
53            let codec = self.config.compression.resolve(&self.config.file_extension);
54            faucet_core::compression::warn_mismatch(&self.config.file_extension, codec);
55            faucet_core::compression::compress_buf(&body, codec)?
56        };
57
58        let payload = bytes::Bytes::from(body);
59        self.storage
60            .write_object(self.bucket_path(), key.to_string(), payload)
61            .set_content_type("application/x-ndjson")
62            .send_unbuffered()
63            .await
64            .map_err(|e| FaucetError::Sink(format!("GCS put object error for key '{key}': {e}")))?;
65        tracing::debug!(key = %key, "Uploaded GCS object");
66        Ok(())
67    }
68
69    /// Upload a pre-encoded Parquet object. Parquet carries its own internal
70    /// compression, so the crate-local `compression` wrapper is deliberately
71    /// **not** applied; the content type advertises Parquet.
72    #[cfg(feature = "arrow")]
73    async fn upload_parquet_object(&self, key: &str, body: Vec<u8>) -> Result<(), FaucetError> {
74        let payload = bytes::Bytes::from(body);
75        self.storage
76            .write_object(self.bucket_path(), key.to_string(), payload)
77            .set_content_type("application/vnd.apache.parquet")
78            .send_unbuffered()
79            .await
80            .map_err(|e| FaucetError::Sink(format!("GCS put object error for key '{key}': {e}")))?;
81        tracing::debug!(key = %key, "Uploaded GCS parquet object");
82        Ok(())
83    }
84
85    /// Compute the effective chunk size combining `batch_size` and
86    /// `max_records_per_file`. `batch_size = 0` removes the batch-size
87    /// limit; `max_records_per_file = None` removes the file-rollover
88    /// limit. When both are unlimited, returns `usize::MAX` (single chunk).
89    fn effective_chunk_size(&self) -> usize {
90        resolve_effective_chunk_size(&self.config)
91    }
92}
93
94#[async_trait]
95impl faucet_core::Sink for GcsSink {
96    fn dataset_uri(&self) -> String {
97        format!("gs://{}/{}", self.config.bucket, self.config.prefix)
98    }
99
100    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
101        if records.is_empty() {
102            return Ok(0);
103        }
104        let chunk = self.effective_chunk_size();
105        let concurrency = self.config.concurrency.max(1);
106        let written = records.len();
107
108        // Parquet path: encode each chunk as a self-contained Parquet object.
109        #[cfg(feature = "arrow")]
110        if matches!(self.config.format, GcsSinkFormat::Parquet) {
111            let uploads: Vec<(String, Vec<u8>)> = records
112                .chunks(chunk)
113                .map(|slice| {
114                    let batch = faucet_core::columnar::values_to_record_batch_inferred(slice)?;
115                    let body = encode_parquet(&batch)?;
116                    Ok::<(String, Vec<u8>), FaucetError>((self.generate_key(), body))
117                })
118                .collect::<Result<_, _>>()?;
119            stream::iter(uploads)
120                .map(|(key, body)| async move { self.upload_parquet_object(&key, body).await })
121                .buffer_unordered(concurrency)
122                .try_collect::<Vec<()>>()
123                .await?;
124            return Ok(written);
125        }
126
127        let uploads: Vec<(String, Vec<u8>)> = records
128            .chunks(chunk)
129            .map(|slice| {
130                let body = Self::serialize_jsonl(slice)?;
131                Ok::<(String, Vec<u8>), FaucetError>((self.generate_key(), body))
132            })
133            .collect::<Result<_, _>>()?;
134
135        stream::iter(uploads)
136            .map(|(key, body)| async move { self.upload_file(&key, body).await })
137            .buffer_unordered(concurrency)
138            .try_collect::<Vec<()>>()
139            .await?;
140
141        Ok(written)
142    }
143
144    /// The GCS sink consumes Arrow `RecordBatch`es natively **only** when
145    /// configured for the [`Parquet`](GcsSinkFormat::Parquet) format; the JSONL
146    /// format stays on the row path (RFC 0002 / #375).
147    #[cfg(feature = "arrow")]
148    fn supports_columnar(&self) -> bool {
149        matches!(self.config.format, GcsSinkFormat::Parquet)
150    }
151
152    /// Write an Arrow `RecordBatch` as one or more self-contained Parquet
153    /// objects (sliced by the effective per-object chunk size), skipping the
154    /// `Value` round-trip. Falls back to the row path for a non-Parquet format.
155    #[cfg(feature = "arrow")]
156    async fn write_batch_columnar(
157        &self,
158        batch: &arrow::array::RecordBatch,
159    ) -> Result<usize, FaucetError> {
160        if batch.num_rows() == 0 {
161            return Ok(0);
162        }
163        if !matches!(self.config.format, GcsSinkFormat::Parquet) {
164            let rows = faucet_core::columnar::record_batch_to_values(batch)?;
165            return self.write_batch(&rows).await;
166        }
167
168        let n = batch.num_rows();
169        let cap = self.effective_chunk_size().min(n).max(1);
170        let concurrency = self.config.concurrency.max(1);
171        let mut uploads: Vec<(String, Vec<u8>)> = Vec::new();
172        let mut offset = 0usize;
173        while offset < n {
174            let len = cap.min(n - offset);
175            let slice = batch.slice(offset, len);
176            uploads.push((self.generate_key(), encode_parquet(&slice)?));
177            offset += len;
178        }
179        stream::iter(uploads)
180            .map(|(key, body)| async move { self.upload_parquet_object(&key, body).await })
181            .buffer_unordered(concurrency)
182            .try_collect::<Vec<()>>()
183            .await?;
184        Ok(n)
185    }
186
187    fn config_schema(&self) -> Value {
188        serde_json::to_value(faucet_core::schema_for!(GcsSinkConfig)).expect("schema serialization")
189    }
190
191    fn connector_name(&self) -> &'static str {
192        "gcs"
193    }
194
195    /// Preflight probe: confirm the configured bucket is reachable and the
196    /// credentials work via a non-mutating `list_objects` call capped at a
197    /// single result. Writes nothing.
198    ///
199    /// The sink only holds a data-plane [`Storage`] client (which exposes no
200    /// list/get-bucket call), so the probe builds a control-plane
201    /// `StorageControl` client on demand using the same credentials.
202    async fn check(
203        &self,
204        ctx: &faucet_core::check::CheckContext,
205    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
206        use faucet_core::check::{CheckReport, Probe};
207
208        let started = std::time::Instant::now();
209
210        // Build a control-plane client (the data-plane Storage client has no
211        // read-only list/get-bucket call). Credential/client-build failures
212        // surface as a failed probe rather than an Err.
213        let control =
214            match build_storage_control(&self.config.auth, self.config.storage_host.as_deref())
215                .await
216            {
217                Ok(c) => c,
218                Err(e) => {
219                    return Ok(CheckReport::single(Probe::fail_hint(
220                        "auth",
221                        started.elapsed(),
222                        e.to_string(),
223                        "check bucket name, credentials, and network",
224                    )));
225                }
226            };
227
228        let probe = match tokio::time::timeout(
229            ctx.timeout,
230            control
231                .list_objects()
232                .set_parent(self.bucket_path())
233                .set_page_size(1_i32)
234                .send(),
235        )
236        .await
237        {
238            Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
239            Ok(Err(e)) => Probe::fail_hint(
240                "auth",
241                started.elapsed(),
242                e.to_string(),
243                "check bucket name, credentials, and network",
244            ),
245            Err(_) => Probe::fail("network", started.elapsed(), "timed out"),
246        };
247        Ok(CheckReport::single(probe))
248    }
249}
250
251/// Pure helper for chunk-size resolution — used by `write_batch` and unit
252/// tested directly so the test surface doesn't need a `Storage` stub.
253fn resolve_effective_chunk_size(config: &GcsSinkConfig) -> usize {
254    let bs = if config.batch_size == 0 {
255        usize::MAX
256    } else {
257        config.batch_size
258    };
259    let mr = config.max_records_per_file.unwrap_or(usize::MAX);
260    bs.min(mr)
261}
262
263/// Pure helper for object-key generation — used by `write_batch` and unit
264/// tested directly. UUIDv7 makes keys time-sortable so a listing of the
265/// destination bucket returns objects in write order.
266fn generate_object_key(prefix: &str, file_extension: &str) -> String {
267    format!("{prefix}{}{file_extension}", uuid::Uuid::now_v7())
268}
269
270/// Encode an Arrow `RecordBatch` into a complete, self-contained Parquet file
271/// (ZSTD-compressed) in memory.
272#[cfg(feature = "arrow")]
273fn encode_parquet(batch: &arrow::array::RecordBatch) -> Result<Vec<u8>, FaucetError> {
274    use parquet::arrow::ArrowWriter;
275    use parquet::basic::{Compression, ZstdLevel};
276    use parquet::file::properties::WriterProperties;
277
278    let props = WriterProperties::builder()
279        .set_compression(Compression::ZSTD(ZstdLevel::default()))
280        .build();
281    let mut buf: Vec<u8> = Vec::new();
282    {
283        let mut writer = ArrowWriter::try_new(&mut buf, batch.schema(), Some(props))
284            .map_err(|e| FaucetError::Sink(format!("parquet writer init failed: {e}")))?;
285        writer
286            .write(batch)
287            .map_err(|e| FaucetError::Sink(format!("parquet write failed: {e}")))?;
288        writer
289            .close()
290            .map_err(|e| FaucetError::Sink(format!("parquet finalize failed: {e}")))?;
291    }
292    Ok(buf)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    // dataset_uri test is skipped: GcsSink::new() requires Google Cloud
300    // credentials (build_storage errors without auth), and no offline
301    // constructor exists.
302
303    #[tokio::test]
304    async fn new_rejects_out_of_range_batch_size() {
305        // Validation runs before any GCS client setup, so this needs no backend.
306        let mut config = GcsSinkConfig::new("bucket");
307        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
308        match GcsSink::new(config).await {
309            Err(FaucetError::Config(m)) => assert!(m.contains("batch_size"), "got: {m}"),
310            _ => panic!("expected a batch_size Config error"),
311        }
312    }
313    use serde_json::json;
314
315    #[test]
316    fn serialize_jsonl_two_records() {
317        let body = GcsSink::serialize_jsonl(&[json!({"a": 1}), json!({"b": 2})]).unwrap();
318        assert_eq!(
319            std::str::from_utf8(&body).unwrap(),
320            "{\"a\":1}\n{\"b\":2}\n"
321        );
322    }
323
324    #[test]
325    fn serialize_jsonl_empty_is_empty() {
326        let body = GcsSink::serialize_jsonl(&[]).unwrap();
327        assert!(body.is_empty());
328    }
329
330    #[test]
331    fn effective_chunk_size_unlimited_when_both_unset() {
332        let cfg = GcsSinkConfig::new("b").with_batch_size(0);
333        assert_eq!(resolve_effective_chunk_size(&cfg), usize::MAX);
334    }
335
336    #[test]
337    fn effective_chunk_size_takes_smaller_limit() {
338        let cfg = GcsSinkConfig::new("b")
339            .with_batch_size(500)
340            .max_records_per_file(100);
341        assert_eq!(resolve_effective_chunk_size(&cfg), 100);
342    }
343
344    #[test]
345    fn effective_chunk_size_uses_batch_size_when_smaller() {
346        let cfg = GcsSinkConfig::new("b")
347            .with_batch_size(50)
348            .max_records_per_file(500);
349        assert_eq!(resolve_effective_chunk_size(&cfg), 50);
350    }
351
352    #[test]
353    fn generate_key_uses_prefix_and_extension() {
354        let key = generate_object_key("out/", ".ndjson");
355        assert!(key.starts_with("out/"));
356        assert!(key.ends_with(".ndjson"));
357    }
358
359    #[test]
360    fn generate_key_yields_distinct_time_ordered_keys() {
361        let a = generate_object_key("p/", ".jsonl");
362        let b = generate_object_key("p/", ".jsonl");
363        assert_ne!(a, b);
364        // UUIDv7 keys are lexically comparable by time within the same
365        // process: the second key generated should compare greater.
366        assert!(a < b, "expected UUIDv7 keys to sort by generation order");
367    }
368
369    // ── Parquet columnar path (feature `arrow`) ──────────────────────────────
370
371    #[cfg(feature = "arrow")]
372    #[test]
373    fn encode_parquet_round_trips_via_reader() {
374        use arrow::array::{Int32Array, RecordBatch, StringArray};
375        use arrow::datatypes::{DataType, Field, Schema};
376        use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
377        use std::sync::Arc;
378
379        let schema = Arc::new(Schema::new(vec![
380            Field::new("id", DataType::Int32, false),
381            Field::new("name", DataType::Utf8, true),
382        ]));
383        let batch = RecordBatch::try_new(
384            schema,
385            vec![
386                Arc::new(Int32Array::from(vec![1, 2, 3])),
387                Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
388            ],
389        )
390        .unwrap();
391
392        let bytes = encode_parquet(&batch).unwrap();
393        assert_eq!(&bytes[..4], b"PAR1");
394
395        let reader = ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes))
396            .unwrap()
397            .build()
398            .unwrap();
399        let total: usize = reader.map(|b| b.unwrap().num_rows()).sum();
400        assert_eq!(total, 3);
401    }
402
403    #[cfg(feature = "compression")]
404    #[test]
405    fn compress_buf_used_for_zstd_extension() {
406        let cfg = GcsSinkConfig::new("bucket").file_extension(".jsonl.zst");
407        let codec = cfg.compression.resolve(&cfg.file_extension);
408        assert_eq!(codec, faucet_core::Compression::Zstd);
409        let compressed = faucet_core::compression::compress_buf(b"hello\n", codec).unwrap();
410        // zstd magic bytes: 0x28 B5 2F FD.
411        assert_eq!(&compressed[..4], b"\x28\xb5\x2f\xfd");
412    }
413}