faucet_cli/serve/handlers/
backfill.rs1use crate::backfill::plan::{parse_boundary, parse_window, range_hash, substitute_unit_tokens};
22use crate::backfill::spec::{has_scoping_tokens, parse_timezone};
23use crate::serve::error::ServeError;
24use crate::serve::load::load_submission;
25use crate::serve::rbac::AuthContext;
26use crate::serve::runner::{self, ConfigFormatWire, SubmitRequest};
27use crate::serve::state::ServerState;
28use axum::Json;
29use axum::extract::{Extension, State};
30use axum::http::StatusCode;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::collections::BTreeMap;
34
35#[derive(Debug, Deserialize)]
37pub struct BackfillSubmitRequest {
38 pub config: String,
40 #[serde(default)]
41 pub config_format: ConfigFormatWire,
42 pub from: String,
44 pub to: String,
46 #[serde(default)]
49 pub window: Option<String>,
50 #[serde(default)]
53 pub timezone: Option<String>,
54 #[serde(default)]
57 pub name: Option<String>,
58 #[serde(default)]
61 pub labels: BTreeMap<String, String>,
62 #[serde(default)]
64 pub timeout_secs: Option<u64>,
65}
66
67#[derive(Debug, Serialize)]
69pub struct BackfillUnitRun {
70 pub unit: String,
71 pub start: String,
72 pub end: String,
73 pub status: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub run_id: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub error: Option<String>,
79}
80
81#[derive(Debug, Serialize)]
83pub struct BackfillSubmitResponse {
84 pub backfill: String,
86 pub descriptor: String,
87 pub planned: usize,
88 pub submitted: usize,
89 pub units: Vec<BackfillUnitRun>,
90}
91
92pub async fn submit_backfill(
94 State(state): State<ServerState>,
95 Extension(actor): Extension<AuthContext>,
96 Json(req): Json<BackfillSubmitRequest>,
97) -> Result<(StatusCode, Json<BackfillSubmitResponse>), ServeError> {
98 let loaded = load_submission(
102 &req.config,
103 req.config_format.into(),
104 state.default_base().as_ref(),
105 )
106 .await?;
107 let unscoped: Vec<&str> = loaded
108 .nodes
109 .iter()
110 .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
111 .filter(|n| !has_scoping_tokens(&n.source.config.to_string()))
112 .map(|n| n.id.as_str())
113 .collect();
114 if !unscoped.is_empty() {
115 return Err(ServeError::BadConfig(format!(
116 "root row(s) {} are not scoped to the backfill window — their source configs \
117 reference no `${{backfill.start}}` / `${{backfill.end}}` / `${{now.*}}` token, \
118 so every window would replay identical data (bookmark-positioned backfills \
119 are CLI-only: `faucet backfill --from-bookmark`)",
120 unscoped.join(", ")
121 )));
122 }
123
124 let spec = loaded.cfg.backfill.clone().unwrap_or_default();
125 let tz = match req.timezone.as_deref().or(spec.timezone.as_deref()) {
126 Some(name) => parse_timezone(name).map_err(|e| ServeError::BadConfig(e.to_string()))?,
127 None => chrono_tz::Tz::UTC,
128 };
129 let window = match req.window.as_deref().or(spec.window.as_deref()) {
130 Some(w) => Some(parse_window(w).map_err(|e| ServeError::BadConfig(e.to_string()))?),
131 None => None,
132 };
133 let from = parse_boundary(&req.from, tz).map_err(|e| ServeError::BadConfig(e.to_string()))?;
134 let to = parse_boundary(&req.to, tz).map_err(|e| ServeError::BadConfig(e.to_string()))?;
135 let units = crate::backfill::plan::plan_windows(from, to, window, tz)
136 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
137
138 let base_name = req
139 .name
140 .clone()
141 .or_else(|| loaded.cfg.name.clone())
142 .unwrap_or_else(|| "pipeline".to_string());
143 let descriptor = format!(
144 "time|{}|{}|{}|{base_name}",
145 from.to_rfc3339(),
146 to.to_rfc3339(),
147 window
148 .map(|w| w.to_string())
149 .unwrap_or_else(|| "whole".into()),
150 );
151 let hash = range_hash(&descriptor);
152
153 let doc: Value = serde_yaml::from_str(&req.config)
157 .map_err(|e| ServeError::BadConfig(format!("config is not valid YAML/JSON: {e}")))?;
158
159 crate::serve::audit::write(
160 &state,
161 &actor,
162 "backfill.submit",
163 None,
164 Some(hash.clone()),
165 "ok",
166 )
167 .await;
168
169 let planned = units.len();
170 let mut reports = Vec::with_capacity(planned);
171 let mut submitted = 0usize;
172 let mut queue_full = false;
173 for unit in units {
174 if queue_full {
175 reports.push(BackfillUnitRun {
176 unit: unit.id.clone(),
177 start: unit.start.to_rfc3339(),
178 end: unit.end.to_rfc3339(),
179 status: "not_submitted".into(),
180 run_id: None,
181 error: Some("run queue full — re-POST the same request to continue".into()),
182 });
183 continue;
184 }
185 let unit_name = format!("{base_name}-backfill-{}", unit.id);
186 let unit_doc = rewrite_unit_doc(&doc, &unit, &unit_name)
187 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
188 let mut labels = req.labels.clone();
189 labels.insert("backfill".into(), hash.clone());
190 labels.insert("backfill_unit".into(), unit.id.clone());
191 let submit = SubmitRequest {
192 config: unit_doc,
193 config_format: ConfigFormatWire::Yaml,
194 name: Some(unit_name),
195 labels,
196 timeout_secs: req.timeout_secs,
197 doctor_first: false,
198 idempotency_key: Some(format!("backfill:{hash}:{}", unit.id)),
199 clock: Some(unit.start.to_rfc3339()),
200 };
201 match runner::submit(state.clone(), submit, actor.clone()).await {
202 Ok(resp) => {
203 submitted += 1;
204 reports.push(BackfillUnitRun {
205 unit: unit.id.clone(),
206 start: unit.start.to_rfc3339(),
207 end: unit.end.to_rfc3339(),
208 status: "submitted".into(),
209 run_id: Some(resp.run_id),
210 error: None,
211 });
212 }
213 Err(ServeError::QueueFull { .. }) => {
214 queue_full = true;
217 reports.push(BackfillUnitRun {
218 unit: unit.id.clone(),
219 start: unit.start.to_rfc3339(),
220 end: unit.end.to_rfc3339(),
221 status: "not_submitted".into(),
222 run_id: None,
223 error: Some("run queue full — re-POST the same request to continue".into()),
224 });
225 }
226 Err(other) => return Err(other),
227 }
228 }
229
230 Ok((
231 StatusCode::ACCEPTED,
232 Json(BackfillSubmitResponse {
233 backfill: hash,
234 descriptor,
235 planned,
236 submitted,
237 units: reports,
238 }),
239 ))
240}
241
242fn rewrite_unit_doc(
247 doc: &Value,
248 unit: &crate::backfill::plan::BackfillUnit,
249 unit_name: &str,
250) -> crate::error::CliResult<String> {
251 let mut d = doc.clone();
252 substitute_unit_tokens(&mut d, unit)?;
253 if let Some(map) = d.as_object_mut() {
254 map.insert("name".into(), Value::String(unit_name.to_string()));
255 map.insert("delivery".into(), Value::String("at_least_once".into()));
256 }
257 serde_yaml::to_string(&d)
258 .map_err(|e| crate::error::CliError::Internal(format!("unit config render: {e}")))
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use serde_json::json;
265
266 #[test]
267 fn rewrite_substitutes_namespaces_and_forces_at_least_once() {
268 let utc: chrono_tz::Tz = "UTC".parse().unwrap();
269 let unit = crate::backfill::plan::BackfillUnit {
270 id: "20260601T000000Z".into(),
271 start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
272 end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
273 };
274 let doc = json!({
275 "version": 1,
276 "name": "orders",
277 "delivery": "exactly_once",
278 "pipeline": {
279 "source": {"type": "rest", "config": {"url": "https://x/o?s=${backfill.start}&e=${backfill.end}"}},
280 "sink": {"type": "jsonl", "config": {"path": "./out-${backfill.start_date}.jsonl"}}
281 }
282 });
283 let out = rewrite_unit_doc(&doc, &unit, "orders-backfill-20260601T000000Z").unwrap();
284 let back: Value = serde_yaml::from_str(&out).unwrap();
285 assert_eq!(back["name"], "orders-backfill-20260601T000000Z");
286 assert_eq!(back["delivery"], "at_least_once");
287 let url = back["pipeline"]["source"]["config"]["url"]
288 .as_str()
289 .unwrap();
290 assert!(url.contains("s=2026-06-01T00:00:00+00:00"), "{url}");
291 assert!(url.contains("e=2026-06-02T00:00:00+00:00"), "{url}");
292 assert_eq!(
293 back["pipeline"]["sink"]["config"]["path"],
294 "./out-2026-06-01.jsonl"
295 );
296 crate::config::parse_with_extension(&out, "yaml").expect("unit doc parses");
298 }
299
300 #[test]
301 fn rewrite_rejects_unknown_token() {
302 let utc: chrono_tz::Tz = "UTC".parse().unwrap();
303 let unit = crate::backfill::plan::BackfillUnit {
304 id: "u".into(),
305 start: parse_boundary("2026-06-01", utc).unwrap(),
306 end: parse_boundary("2026-06-02", utc).unwrap(),
307 };
308 let doc = json!({"pipeline": {"source": {"config": {"q": "${backfill.oops}"}}}});
309 assert!(rewrite_unit_doc(&doc, &unit, "n").is_err());
310 }
311}