1use 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
13pub 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 fn bucket_path(&self) -> String {
29 format!("projects/_/buckets/{}", self.config.bucket)
30 }
31
32 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 fn generate_key(&self) -> String {
46 generate_object_key(&self.config.prefix, &self.config.file_extension)
47 }
48
49 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 #[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 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 #[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 #[cfg(feature = "arrow")]
148 fn supports_columnar(&self) -> bool {
149 matches!(self.config.format, GcsSinkFormat::Parquet)
150 }
151
152 #[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 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 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
251fn 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
263fn generate_object_key(prefix: &str, file_extension: &str) -> String {
267 format!("{prefix}{}{file_extension}", uuid::Uuid::now_v7())
268}
269
270#[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 #[tokio::test]
304 async fn new_rejects_out_of_range_batch_size() {
305 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 assert!(a < b, "expected UUIDv7 keys to sort by generation order");
367 }
368
369 #[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 assert_eq!(&compressed[..4], b"\x28\xb5\x2f\xfd");
412 }
413}