Skip to main content

faucet_sink_s3/
sink.rs

1//! S3 sink executor.
2
3use 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
10/// A sink that writes JSON records to S3 as JSON Lines files.
11pub struct S3Sink {
12    config: S3SinkConfig,
13    client: Client,
14}
15
16impl S3Sink {
17    /// Create a new S3 sink from the given configuration.
18    ///
19    /// Builds the S3 client eagerly so it is reused across calls.
20    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    /// Build an S3 client from the configuration.
27    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    /// Serialize a slice of records as JSON Lines bytes.
44    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    /// Generate a unique S3 key for a file.
56    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    /// Upload a single JSONL file to S3.
62    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    /// Preflight probe: confirm the configured bucket is reachable and the
96    /// credentials work via a non-mutating `HeadBucket` call. Uploads nothing.
97    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        // Effective per-object cap is the smaller of `batch_size` (when set)
128        // and `max_records_per_file` (when set). When neither caps the chunk,
129        // the whole slice is written as a single object.
130        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        // Pre-serialize each chunk and generate keys before uploading.
149        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    /// Helper to build an S3Sink synchronously for tests that never make network calls.
181    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        // UUID is 36 chars
227        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        // No prefix means key starts with UUID
236        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        // White-box: confirm the codec resolved from file_extension is Gzip
255        // and that compress_buf produces a gzip-magic-prefixed buffer.
256        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        // gzip magic bytes.
261        assert_eq!(&compressed[..2], b"\x1f\x8b");
262    }
263}