1use crate::config::S3SinkConfig;
4use async_trait::async_trait;
5use aws_sdk_s3::Client;
6use faucet_core::FaucetError;
7use futures::stream::{self, StreamExt, TryStreamExt};
8use serde_json::Value;
9
10pub struct S3Sink {
12 config: S3SinkConfig,
13 client: Client,
14}
15
16impl S3Sink {
17 pub async fn new(config: S3SinkConfig) -> Result<Self, FaucetError> {
21 faucet_core::validate_batch_size(config.batch_size)?;
22 let client = Self::build_client(&config).await?;
23 Ok(Self { config, client })
24 }
25
26 async fn build_client(config: &S3SinkConfig) -> Result<Client, FaucetError> {
28 let mut config_loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
29
30 if let Some(ref region) = config.region {
31 config_loader = config_loader.region(aws_config::Region::new(region.clone()));
32 }
33
34 if let Some(ref endpoint) = config.endpoint_url {
35 config_loader = config_loader.endpoint_url(endpoint);
36 }
37
38 let sdk_config = config_loader.load().await;
39 let client = Client::new(&sdk_config);
40 Ok(client)
41 }
42
43 fn serialize_jsonl(records: &[Value]) -> Result<Vec<u8>, FaucetError> {
45 let mut buf: Vec<u8> = Vec::new();
46 for record in records {
47 let line = serde_json::to_vec(record)
48 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
49 buf.extend_from_slice(&line);
50 buf.push(b'\n');
51 }
52 Ok(buf)
53 }
54
55 fn generate_key(&self) -> String {
57 let id = uuid::Uuid::new_v4();
58 format!("{}{}{}", self.config.prefix, id, self.config.file_extension)
59 }
60
61 async fn upload_file(&self, key: &str, body: Vec<u8>) -> Result<(), FaucetError> {
63 #[cfg(feature = "compression")]
64 let body = {
65 let codec = self.config.compression.resolve(&self.config.file_extension);
66 faucet_core::compression::warn_mismatch(&self.config.file_extension, codec);
67 faucet_core::compression::compress_buf(&body, codec)?
68 };
69
70 self.client
71 .put_object()
72 .bucket(&self.config.bucket)
73 .key(key)
74 .body(body.into())
75 .content_type("application/x-ndjson")
76 .send()
77 .await
78 .map_err(|e| FaucetError::Sink(format!("S3 put object error for key '{key}': {e}")))?;
79
80 tracing::debug!(key = %key, "Uploaded S3 object");
81 Ok(())
82 }
83}
84
85#[async_trait]
86impl faucet_core::Sink for S3Sink {
87 fn config_schema(&self) -> serde_json::Value {
88 serde_json::to_value(faucet_core::schema_for!(S3SinkConfig)).expect("schema serialization")
89 }
90
91 fn dataset_uri(&self) -> String {
92 format!("s3://{}/{}", self.config.bucket, self.config.prefix)
93 }
94
95 async fn check(
98 &self,
99 ctx: &faucet_core::check::CheckContext,
100 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
101 use faucet_core::check::{CheckReport, Probe};
102
103 let started = std::time::Instant::now();
104 let probe = match tokio::time::timeout(
105 ctx.timeout,
106 self.client.head_bucket().bucket(&self.config.bucket).send(),
107 )
108 .await
109 {
110 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
111 Ok(Err(e)) => Probe::fail_hint(
112 "auth",
113 started.elapsed(),
114 e.to_string(),
115 "check bucket name, credentials, and network",
116 ),
117 Err(_) => Probe::fail("network", started.elapsed(), "timed out"),
118 };
119 Ok(CheckReport::single(probe))
120 }
121
122 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
123 if records.is_empty() {
124 return Ok(0);
125 }
126
127 let chunk_cap: Option<usize> =
131 match (self.config.batch_size, self.config.max_records_per_file) {
132 (0, None) => None,
133 (0, Some(0)) => None,
134 (0, Some(max)) => Some(max),
135 (bs, None) => Some(bs),
136 (bs, Some(0)) => Some(bs),
137 (bs, Some(max)) => Some(bs.min(max)),
138 };
139
140 let chunks: Vec<&[Value]> = match chunk_cap {
141 Some(cap) => records.chunks(cap).collect(),
142 None => vec![records],
143 };
144
145 let total_files = chunks.len();
146 let concurrency = self.config.concurrency.max(1);
147
148 let prepared: Vec<(String, Vec<u8>)> = chunks
150 .iter()
151 .map(|chunk| {
152 let body = Self::serialize_jsonl(chunk)?;
153 let key = self.generate_key();
154 Ok((key, body))
155 })
156 .collect::<Result<Vec<_>, FaucetError>>()?;
157
158 stream::iter(prepared)
159 .map(|(key, body)| async move { self.upload_file(&key, body).await })
160 .buffer_unordered(concurrency)
161 .try_collect::<Vec<()>>()
162 .await?;
163
164 tracing::info!(
165 records = records.len(),
166 files = total_files,
167 "S3 batch write complete"
168 );
169 Ok(records.len())
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::config::S3SinkConfig;
177 use faucet_core::Sink as _;
178 use serde_json::json;
179
180 fn test_sink(config: S3SinkConfig) -> S3Sink {
182 let sdk_config = aws_config::SdkConfig::builder()
183 .behavior_version(aws_config::BehaviorVersion::latest())
184 .build();
185 let client = Client::new(&sdk_config);
186 S3Sink { config, client }
187 }
188
189 #[test]
190 fn dataset_uri_includes_bucket_and_prefix() {
191 let sink = test_sink(S3SinkConfig::new("my-bucket").prefix("data/events/"));
192 assert_eq!(sink.dataset_uri(), "s3://my-bucket/data/events/");
193 }
194
195 #[test]
196 fn serialize_jsonl_produces_newline_delimited() {
197 let records = vec![
198 json!({"id": 1, "name": "Alice"}),
199 json!({"id": 2, "name": "Bob"}),
200 ];
201 let result = S3Sink::serialize_jsonl(&records).unwrap();
202 let text = String::from_utf8(result).unwrap();
203 let lines: Vec<&str> = text.trim().split('\n').collect();
204 assert_eq!(lines.len(), 2);
205
206 let first: Value = serde_json::from_str(lines[0]).unwrap();
207 assert_eq!(first["id"], 1);
208 }
209
210 #[test]
211 fn serialize_jsonl_empty() {
212 let result = S3Sink::serialize_jsonl(&[]).unwrap();
213 assert!(result.is_empty());
214 }
215
216 #[test]
217 fn generate_key_uses_prefix_and_extension() {
218 let sink = test_sink(
219 S3SinkConfig::new("bucket")
220 .prefix("data/")
221 .file_extension(".jsonl"),
222 );
223 let key = sink.generate_key();
224 assert!(key.starts_with("data/"));
225 assert!(key.ends_with(".jsonl"));
226 assert!(key.len() > "data/".len() + ".jsonl".len());
228 }
229
230 #[test]
231 fn generate_key_no_prefix() {
232 let sink = test_sink(S3SinkConfig::new("bucket"));
233 let key = sink.generate_key();
234 assert!(key.ends_with(".jsonl"));
235 assert!(!key.starts_with('/'));
237 }
238
239 #[tokio::test]
240 async fn new_rejects_out_of_range_batch_size() {
241 let mut config = S3SinkConfig::new("bucket");
242 config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
243 match S3Sink::new(config).await {
244 Err(faucet_core::FaucetError::Config(m)) => {
245 assert!(m.contains("batch_size"), "got: {m}")
246 }
247 _ => panic!("expected a batch_size Config error"),
248 }
249 }
250
251 #[cfg(feature = "compression")]
252 #[test]
253 fn compress_buf_used_for_gzip_extension() {
254 let cfg = S3SinkConfig::new("bucket").file_extension(".jsonl.gz");
257 let codec = cfg.compression.resolve(&cfg.file_extension);
258 assert_eq!(codec, faucet_core::Compression::Gzip);
259 let compressed = faucet_core::compression::compress_buf(b"hello\n", codec).unwrap();
260 assert_eq!(&compressed[..2], b"\x1f\x8b");
262 }
263}