1use super::{ComponentConfig, redact};
5use serde::Deserialize;
6use std::num::{NonZeroU32, NonZeroUsize};
7use std::time::Duration;
8
9#[derive(Clone, PartialEq)]
30#[non_exhaustive]
31pub struct FeedSection {
32 backend: ComponentConfig,
33 pub units_held: Option<NonZeroUsize>,
36 pub attempt_cap: Option<NonZeroU32>,
43 pub location: Option<String>,
48 pub settling_window: Option<Duration>,
55}
56
57impl std::fmt::Debug for FeedSection {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 let FeedSection {
63 backend,
64 units_held,
65 attempt_cap,
66 location,
67 settling_window,
68 } = self;
69 f.debug_struct("FeedSection")
70 .field("backend", backend)
71 .field("units_held", units_held)
72 .field("attempt_cap", attempt_cap)
73 .field("location", &location.as_deref().map(redact::url))
74 .field("settling_window", settling_window)
75 .finish()
76 }
77}
78
79impl FeedSection {
80 #[must_use]
96 pub fn new(mut backend: ComponentConfig) -> FeedSection {
97 backend.set_section("feed");
98 FeedSection {
99 backend,
100 units_held: None,
101 attempt_cap: None,
102 location: None,
103 settling_window: None,
104 }
105 }
106
107 #[must_use]
111 pub fn backend(&self) -> &ComponentConfig {
112 &self.backend
113 }
114
115 #[must_use]
119 pub fn settling_window_or_default(&self) -> Duration {
120 self.settling_window
121 .unwrap_or(crate::feed::DEFAULT_SETTLING_WINDOW)
122 }
123}
124
125impl<'de> Deserialize<'de> for FeedSection {
126 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
127 where
128 D: serde::Deserializer<'de>,
129 {
130 use serde::de::Error as _;
131 let mut keys = serde_yaml::Mapping::deserialize(deserializer)?;
132 let units_held = keys
133 .remove("units_held")
134 .map(|v| {
135 Option::<NonZeroUsize>::deserialize(v)
136 .map_err(|e| D::Error::custom(format!("units_held: {e}")))
137 })
138 .transpose()?
139 .flatten();
140 let attempt_cap = keys
141 .remove("attempt_cap")
142 .map(|v| {
143 Option::<NonZeroU32>::deserialize(v)
144 .map_err(|e| D::Error::custom(format!("attempt_cap: {e}")))
145 })
146 .transpose()?
147 .flatten();
148 let location = keys
149 .remove("location")
150 .map(|v| {
151 Option::<String>::deserialize(v)
152 .map_err(|e| D::Error::custom(format!("location: {e}")))
153 })
154 .transpose()?
155 .flatten();
156 let settling_window = keys
157 .remove("settling_window")
158 .map(|v| {
159 humantime_serde::Serde::<Option<Duration>>::deserialize(v)
160 .map(humantime_serde::Serde::into_inner)
161 .map_err(|e| D::Error::custom(format!("settling_window: {e}")))
162 })
163 .transpose()?
164 .flatten();
165 if keys.len() != 1 {
168 let found: Vec<String> = keys
169 .keys()
170 .map(|k| match k.as_str() {
171 Some(name) => format!("`{name}`"),
172 None => "a non-string key".to_owned(),
173 })
174 .collect();
175 return Err(D::Error::custom(if found.is_empty() {
176 "`feed:` names no backend; add one, e.g. `memory: {}`".to_owned()
177 } else {
178 format!(
179 "`feed:` must name exactly one backend beside `units_held`, \
180 `attempt_cap`, `location` and `settling_window`; found {}",
181 found.join(", ")
182 )
183 }));
184 }
185 let mut backend = ComponentConfig::deserialize(serde_yaml::Value::Mapping(keys))
186 .map_err(D::Error::custom)?;
187 if backend.has_chunk() {
190 return Err(D::Error::custom(format!(
191 "{}: `chunk:` is only valid on a sink section",
192 backend.type_tag()
193 )));
194 }
195 backend.set_section("feed");
196 Ok(FeedSection {
197 backend,
198 units_held,
199 attempt_cap,
200 location,
201 settling_window,
202 })
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn parse(yaml: &str) -> Result<FeedSection, serde_yaml::Error> {
211 serde_yaml::from_str(yaml)
212 }
213
214 #[derive(Debug, Deserialize)]
215 #[serde(deny_unknown_fields)]
216 struct Body {
217 x: u32,
218 }
219
220 #[test]
223 fn one_backend_sits_beside_the_common_keys() {
224 let s = parse(
225 "units_held: 12\nattempt_cap: 3\nlocation: s3://ops/jobs/a/\n\
226 settling_window: 2m 30s\nmemory: { x: 7 }\n",
227 )
228 .unwrap();
229 assert_eq!(s.backend().type_tag(), "memory");
230 assert_eq!(s.units_held, NonZeroUsize::new(12));
231 assert_eq!(s.attempt_cap, NonZeroU32::new(3));
232 assert_eq!(s.location.as_deref(), Some("s3://ops/jobs/a/"));
233 assert_eq!(s.settling_window, Some(Duration::from_secs(150)));
234 let body: Body = s.backend().deserialize_into().unwrap();
235 assert_eq!(body.x, 7);
236
237 let bare = parse("memory: {}\nlocation: null\nsettling_window: null\n").unwrap();
238 assert_eq!(bare.units_held, None);
239 assert_eq!(bare.attempt_cap, None);
240 assert_eq!(bare.location, None);
241 assert_eq!(bare.settling_window, None);
242
243 let built = FeedSection::new(ComponentConfig::new(
244 "memory",
245 serde_yaml::Value::Mapping(Default::default()),
246 ));
247 assert_eq!(built.location, None);
248 assert_eq!(built.settling_window, None);
249 }
250
251 #[test]
253 fn a_section_with_no_backend_is_refused() {
254 let err = parse("units_held: 4\n").unwrap_err().to_string();
255 assert!(err.contains("names no backend"), "{err}");
256 }
257
258 #[test]
260 fn two_backends_are_refused_naming_both() {
261 let err = parse("memory: {}\nother: {}\n").unwrap_err().to_string();
262 assert!(err.contains("exactly one backend"), "{err}");
263 assert!(err.contains("`memory`") && err.contains("`other`"), "{err}");
264 }
265
266 #[test]
269 fn a_misspelled_common_key_is_refused_by_name() {
270 let err = parse("memory: {}\nunit_held: 4\n").unwrap_err().to_string();
271 assert!(err.contains("`unit_held`"), "{err}");
272
273 let err = parse("memory: {}\nlocaton: s3://ops/a/\n")
274 .unwrap_err()
275 .to_string();
276 assert!(err.contains("`locaton`"), "{err}");
277 assert!(err.contains("`location`"), "{err}");
278
279 let err = parse("memory: {}\nsettling_windw: 5m\n")
280 .unwrap_err()
281 .to_string();
282 assert!(err.contains("`settling_windw`"), "{err}");
283 assert!(err.contains("`settling_window`"), "{err}");
284 }
285
286 #[test]
289 fn a_settling_window_that_is_not_a_duration_is_refused() {
290 for value in ["[1]", "lots", "5"] {
291 let err = parse(&format!("memory: {{}}\nsettling_window: {value}\n"))
292 .unwrap_err()
293 .to_string();
294 assert!(err.starts_with("settling_window: "), "{value}: {err}");
295 }
296 }
297
298 #[test]
301 fn an_absent_settling_window_reads_as_five_minutes() {
302 let s = parse("sqs: {}\n").unwrap();
303 assert_eq!(s.settling_window_or_default(), Duration::from_secs(300));
304 let s = parse("sqs: {}\nsettling_window: 90s\n").unwrap();
305 assert_eq!(s.settling_window_or_default(), Duration::from_secs(90));
306 }
307
308 fn load(feed: &str) -> Result<crate::config::PipelineConfig, String> {
309 let yaml = format!(
310 "pipeline: {{ name: demo }}\nsource: {{ memory: {{}} }}\nsink: {{ memory: {{}} }}\n\
311 feed: {feed}\n"
312 );
313 crate::config::PipelineConfig::from_str(&yaml).map_err(|e| e.to_string())
314 }
315
316 #[test]
318 fn a_zero_settling_window_is_refused() {
319 let err = load("{ sqs: {}, settling_window: 0s }").unwrap_err();
320 assert!(err.contains("`feed.settling_window`"), "{err}");
321 }
322
323 #[test]
326 fn a_settling_window_below_the_floor_is_refused() {
327 let cfg = load("{ sqs: {}, settling_window: 20s }").expect("the floor loads");
328 assert_eq!(
329 cfg.feed.unwrap().settling_window,
330 Some(Duration::from_secs(20))
331 );
332 let err = load("{ sqs: {}, settling_window: 19s 999ms }").unwrap_err();
333 assert!(
334 err.contains("`feed.settling_window`") && err.contains("20s"),
335 "{err}"
336 );
337 }
338
339 #[test]
342 fn a_settling_window_above_a_day_is_refused() {
343 load("{ sqs: {}, settling_window: 24h }").expect("a day loads");
344 for value in ["24h 1s", "1000000years"] {
345 let err = load(&format!("{{ sqs: {{}}, settling_window: {value} }}")).unwrap_err();
346 assert!(
347 err.contains("`feed.settling_window`") && err.contains("1day"),
348 "{value}: {err}"
349 );
350 }
351 }
352
353 #[test]
356 fn a_memory_feed_with_a_settling_window_is_refused() {
357 let err = load("{ memory: {}, settling_window: 1m }").unwrap_err();
358 assert!(
359 err.contains("`feed.settling_window`") && err.contains("`memory`"),
360 "{err}"
361 );
362 }
363
364 #[test]
367 fn a_location_that_is_not_a_string_is_refused() {
368 let err = parse("memory: {}\nlocation: [1]\n")
369 .unwrap_err()
370 .to_string();
371 assert!(err.starts_with("location: "), "{err}");
372 }
373
374 #[test]
376 fn a_section_debug_redacts_the_location() {
377 let s = parse("memory: {}\nlocation: s3://u:secret@ops/jobs/a?x=1\nsettling_window: 1m\n")
378 .unwrap();
379 let shown = format!("{s:?}");
380 assert!(shown.contains("ops/jobs/a"), "{shown}");
381 assert!(shown.contains("settling_window: Some(60s)"), "{shown}");
382 assert!(!shown.contains("secret"), "{shown}");
383 assert!(!shown.contains("x=1"), "{shown}");
384 }
385
386 #[test]
389 fn a_memory_feed_with_a_location_is_refused_at_load() {
390 let yaml = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n\
391 feed: { memory: {}, location: s3://ops/jobs/a/ }\n";
392 let err = crate::config::PipelineConfig::from_str(yaml)
393 .unwrap_err()
394 .to_string();
395 assert!(
396 err.contains("`feed.location`") && err.contains("`memory`"),
397 "{err}"
398 );
399 }
400
401 #[test]
404 fn a_location_beside_a_durable_backend_loads() {
405 let yaml = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n\
406 feed: { sqs: {}, location: s3://ops/jobs/a/ }\n";
407 let cfg = crate::config::PipelineConfig::from_str(yaml).expect("loads");
408 let feed = cfg.feed.expect("feed section");
409 assert_eq!(feed.location.as_deref(), Some("s3://ops/jobs/a/"));
410 }
411
412 #[test]
414 fn zero_units_held_is_refused() {
415 let err = parse("memory: {}\nunits_held: 0\n")
416 .unwrap_err()
417 .to_string();
418 assert!(err.starts_with("units_held: "), "{err}");
419 }
420
421 #[test]
423 fn chunk_under_the_backend_is_refused() {
424 let err = parse("memory: { chunk: { target_bytes: 1MiB } }\n")
425 .unwrap_err()
426 .to_string();
427 assert!(err.contains("chunk"), "{err}");
428 }
429
430 #[test]
432 fn chunk_under_a_built_backend_is_refused() {
433 let base = "pipeline: { name: demo }\nsource: { memory: {} }\nsink: { memory: {} }\n";
434 let mut cfg = crate::config::PipelineConfig::from_str(base).unwrap();
435 let body = serde_yaml::from_str("chunk: { target_bytes: 1MiB }").unwrap();
436 cfg.feed = Some(FeedSection::new(ComponentConfig::new("memory", body)));
437 let err = cfg.validate().unwrap_err().to_string();
438 assert!(err.contains("not `feed`"), "{err}");
439 }
440
441 #[test]
444 fn backend_errors_are_rooted_at_feed() {
445 let s = parse("memory: { x: lots }\n").unwrap();
446 let err = s
447 .backend()
448 .deserialize_into::<Body>()
449 .unwrap_err()
450 .to_string();
451 assert!(err.starts_with("feed.memory.x"), "{err}");
452
453 let body = serde_yaml::from_str("x: lots").unwrap();
454 let built = FeedSection::new(ComponentConfig::new("memory", body));
455 let err = built
456 .backend()
457 .deserialize_into::<Body>()
458 .unwrap_err()
459 .to_string();
460 assert!(err.starts_with("feed.memory.x"), "{err}");
461 }
462}