1use 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
11pub 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 fn bucket_path(&self) -> String {
26 format!("projects/_/buckets/{}", self.config.bucket)
27 }
28
29 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 fn generate_key(&self) -> String {
43 generate_object_key(&self.config.prefix, &self.config.file_extension)
44 }
45
46 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 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 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 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
170fn 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
182fn 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 #[tokio::test]
198 async fn new_rejects_out_of_range_batch_size() {
199 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 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 assert_eq!(&compressed[..4], b"\x28\xb5\x2f\xfd");
272 }
273}