1use faucet_core::DEFAULT_BATCH_SIZE;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
9#[serde(rename_all = "snake_case")]
10pub enum S3SinkFormat {
11 #[default]
13 JsonLines,
14 #[cfg(feature = "arrow")]
21 Parquet,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26pub struct S3SinkConfig {
27 pub bucket: String,
29 pub prefix: String,
31 #[serde(default)]
35 pub format: S3SinkFormat,
36 pub region: Option<String>,
38 pub endpoint_url: Option<String>,
40 pub file_extension: String,
42 pub max_records_per_file: Option<usize>,
47 pub concurrency: usize,
49 #[serde(default = "default_batch_size")]
66 pub batch_size: usize,
67 #[cfg(feature = "compression")]
74 #[serde(default)]
75 pub compression: faucet_core::CompressionConfig,
76}
77
78fn default_batch_size() -> usize {
79 DEFAULT_BATCH_SIZE
80}
81
82impl S3SinkConfig {
83 pub fn new(bucket: impl Into<String>) -> Self {
85 Self {
86 bucket: bucket.into(),
87 prefix: String::new(),
88 format: S3SinkFormat::default(),
89 region: None,
90 endpoint_url: None,
91 file_extension: ".jsonl".to_string(),
92 max_records_per_file: None,
93 concurrency: 10,
94 batch_size: DEFAULT_BATCH_SIZE,
95 #[cfg(feature = "compression")]
96 compression: faucet_core::CompressionConfig::Auto,
97 }
98 }
99
100 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
102 self.prefix = prefix.into();
103 self
104 }
105
106 pub fn format(mut self, format: S3SinkFormat) -> Self {
109 self.format = format;
110 self
111 }
112
113 pub fn region(mut self, region: impl Into<String>) -> Self {
115 self.region = Some(region.into());
116 self
117 }
118
119 pub fn endpoint_url(mut self, url: impl Into<String>) -> Self {
121 self.endpoint_url = Some(url.into());
122 self
123 }
124
125 pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
127 self.file_extension = ext.into();
128 self
129 }
130
131 pub fn max_records_per_file(mut self, max: usize) -> Self {
133 self.max_records_per_file = Some(max);
134 self
135 }
136
137 pub fn concurrency(mut self, concurrency: usize) -> Self {
139 self.concurrency = concurrency;
140 self
141 }
142
143 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
151 self.batch_size = batch_size;
152 self
153 }
154
155 #[cfg(feature = "compression")]
157 pub fn compression(mut self, c: faucet_core::CompressionConfig) -> Self {
158 self.compression = c;
159 self
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn default_config() {
169 let config = S3SinkConfig::new("my-bucket");
170 assert_eq!(config.bucket, "my-bucket");
171 assert_eq!(config.prefix, "");
172 assert!(config.region.is_none());
173 assert!(config.endpoint_url.is_none());
174 assert_eq!(config.file_extension, ".jsonl");
175 assert!(config.max_records_per_file.is_none());
176 }
177
178 #[test]
179 fn builder_methods() {
180 let config = S3SinkConfig::new("my-bucket")
181 .prefix("output/")
182 .region("eu-west-1")
183 .endpoint_url("http://localhost:9000")
184 .file_extension(".json")
185 .max_records_per_file(1000);
186
187 assert_eq!(config.bucket, "my-bucket");
188 assert_eq!(config.prefix, "output/");
189 assert_eq!(config.region.as_deref(), Some("eu-west-1"));
190 assert_eq!(
191 config.endpoint_url.as_deref(),
192 Some("http://localhost:9000")
193 );
194 assert_eq!(config.file_extension, ".json");
195 assert_eq!(config.max_records_per_file, Some(1000));
196 }
197
198 #[test]
199 fn batch_size_defaults_to_default_batch_size() {
200 let config = S3SinkConfig::new("my-bucket");
201 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
202 }
203
204 #[test]
205 fn with_batch_size_overrides_default() {
206 let config = S3SinkConfig::new("my-bucket").with_batch_size(500);
207 assert_eq!(config.batch_size, 500);
208 }
209
210 #[test]
211 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
212 let config = S3SinkConfig::new("my-bucket").with_batch_size(0);
213 assert_eq!(config.batch_size, 0);
214 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
215 }
216
217 #[test]
218 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
219 let config =
220 S3SinkConfig::new("my-bucket").with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
221 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
222 }
223
224 #[test]
225 fn batch_size_deserializes_from_json() {
226 let json = r#"{
227 "bucket": "my-bucket",
228 "prefix": "",
229 "region": null,
230 "endpoint_url": null,
231 "file_extension": ".jsonl",
232 "max_records_per_file": null,
233 "concurrency": 10,
234 "batch_size": 250
235 }"#;
236 let config: S3SinkConfig = serde_json::from_str(json).unwrap();
237 assert_eq!(config.batch_size, 250);
238 }
239
240 #[test]
241 fn batch_size_defaults_when_omitted_from_json() {
242 let json = r#"{
243 "bucket": "my-bucket",
244 "prefix": "",
245 "region": null,
246 "endpoint_url": null,
247 "file_extension": ".jsonl",
248 "max_records_per_file": null,
249 "concurrency": 10
250 }"#;
251 let config: S3SinkConfig = serde_json::from_str(json).unwrap();
252 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
253 }
254
255 #[cfg(feature = "compression")]
256 #[test]
257 fn compression_config_round_trips() {
258 let json = r#"{
259 "bucket": "b",
260 "prefix": "",
261 "region": null,
262 "endpoint_url": null,
263 "file_extension": ".jsonl.gz",
264 "max_records_per_file": null,
265 "concurrency": 1,
266 "batch_size": 0,
267 "compression": "gzip"
268 }"#;
269 let config: S3SinkConfig = serde_json::from_str(json).unwrap();
270 assert_eq!(config.compression, faucet_core::CompressionConfig::Gzip);
271 }
272
273 #[cfg(feature = "compression")]
274 #[test]
275 fn compression_default_is_auto() {
276 let cfg = S3SinkConfig::new("bucket");
277 assert_eq!(cfg.compression, faucet_core::CompressionConfig::Auto);
278 }
279}