faucet_cli/backfill/
spec.rs1use crate::error::{CliError, CliResult};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
14#[serde(deny_unknown_fields)]
15pub struct BackfillSpec {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub window: Option<String>,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub concurrency: Option<usize>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub timezone: Option<String>,
30}
31
32impl BackfillSpec {
33 pub fn validate(&self, source_configs: &[String]) -> CliResult<()> {
41 if let Some(w) = &self.window {
42 crate::backfill::plan::parse_window(w)?;
43 }
44 if self.concurrency == Some(0) {
45 return Err(CliError::Config(
46 "backfill.concurrency must be at least 1".into(),
47 ));
48 }
49 if let Some(tz) = &self.timezone {
50 parse_timezone(tz)?;
51 }
52 if !source_configs.is_empty() && !source_configs.iter().any(|c| has_scoping_tokens(c)) {
53 return Err(CliError::Config(
54 "the config has a `backfill:` block but no source config references a \
55 `${backfill.start}` / `${backfill.end}` / `${now.*}` token — every window \
56 would replay identical data. Scope the source to the window (e.g. \
57 `query: SELECT * FROM t WHERE updated_at >= '${backfill.start}' AND \
58 updated_at < '${backfill.end}'`), or drop the block and use \
59 `faucet backfill --from-bookmark` instead"
60 .into(),
61 ));
62 }
63 Ok(())
64 }
65}
66
67pub fn has_scoping_tokens(serialized_config: &str) -> bool {
69 serialized_config.contains("${backfill.") || serialized_config.contains("${now.")
70}
71
72pub fn parse_timezone(name: &str) -> CliResult<chrono_tz::Tz> {
74 name.parse::<chrono_tz::Tz>().map_err(|_| {
75 CliError::Config(format!(
76 "'{name}' is not a valid IANA timezone (e.g. UTC, America/New_York)"
77 ))
78 })
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn parses_full_block() {
87 let yaml = "window: 1d\nconcurrency: 4\ntimezone: America/New_York\n";
88 let spec: BackfillSpec = serde_yaml::from_str(yaml).unwrap();
89 assert_eq!(spec.window.as_deref(), Some("1d"));
90 assert_eq!(spec.concurrency, Some(4));
91 spec.validate(&["${backfill.start}".into()]).unwrap();
92 }
93
94 #[test]
95 fn rejects_unknown_field() {
96 assert!(serde_yaml::from_str::<BackfillSpec>("bogus: 1\n").is_err());
97 }
98
99 #[test]
100 fn rejects_bad_window_concurrency_timezone() {
101 let spec = BackfillSpec {
102 window: Some("soon".into()),
103 ..Default::default()
104 };
105 assert!(spec.validate(&[]).is_err());
106 let spec = BackfillSpec {
107 concurrency: Some(0),
108 ..Default::default()
109 };
110 assert!(spec.validate(&[]).is_err());
111 let spec = BackfillSpec {
112 timezone: Some("Mars/Olympus".into()),
113 ..Default::default()
114 };
115 assert!(spec.validate(&[]).is_err());
116 }
117
118 #[test]
119 fn rejects_unscoped_source_with_block() {
120 let spec = BackfillSpec::default();
121 let err = spec
122 .validate(&[r#"{"query":"SELECT * FROM t"}"#.into()])
123 .unwrap_err();
124 assert!(err.to_string().contains("${backfill.start}"), "{err}");
125 spec.validate(&[r#"{"prefix":"dt=${now.date}/"}"#.into()])
127 .unwrap();
128 spec.validate(&[]).unwrap();
130 }
131
132 #[test]
133 fn timezone_parses() {
134 parse_timezone("UTC").unwrap();
135 parse_timezone("America/New_York").unwrap();
136 assert!(parse_timezone("Nowhere").is_err());
137 }
138}