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 #[serde(default)]
71 pub callback: Option<crate::serve::callback::CallbackSpec>,
72}
73
74#[derive(Debug, Serialize)]
76pub struct BackfillUnitRun {
77 pub unit: String,
78 pub start: String,
79 pub end: String,
80 pub status: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub run_id: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86}
87
88#[derive(Debug, Serialize)]
90pub struct BackfillSubmitResponse {
91 pub backfill: String,
93 pub descriptor: String,
94 pub planned: usize,
95 pub submitted: usize,
96 pub units: Vec<BackfillUnitRun>,
97}
98
99pub async fn submit_backfill(
101 State(state): State<ServerState>,
102 Extension(actor): Extension<AuthContext>,
103 Json(req): Json<BackfillSubmitRequest>,
104) -> Result<(StatusCode, Json<BackfillSubmitResponse>), ServeError> {
105 if req.callback.is_some() {
109 return Err(ServeError::Unprocessable {
110 message: "`callback` is not supported on /v1/backfill: this submits one run \
111 per window unit, so there is no single run for a completion callback \
112 to describe. Poll `GET /v1/runs?labels=backfill:<hash>` for unit \
113 status, or submit the units individually via `POST /v1/runs` with a \
114 callback on each"
115 .to_string(),
116 details: None,
117 });
118 }
119
120 let loaded = load_submission(
124 &req.config,
125 req.config_format.into(),
126 state.default_base().as_ref(),
127 )
128 .await?;
129 let unscoped: Vec<&str> = loaded
130 .nodes
131 .iter()
132 .filter(|n| matches!(n.role, crate::expand::NodeRole::Root))
133 .filter(|n| !has_scoping_tokens(&n.source.config.to_string()))
134 .map(|n| n.id.as_str())
135 .collect();
136 if !unscoped.is_empty() {
137 return Err(ServeError::BadConfig(format!(
138 "root row(s) {} are not scoped to the backfill window — their source configs \
139 reference no `${{backfill.start}}` / `${{backfill.end}}` / `${{now.*}}` token, \
140 so every window would replay identical data (bookmark-positioned backfills \
141 are CLI-only: `faucet backfill --from-bookmark`)",
142 unscoped.join(", ")
143 )));
144 }
145
146 let spec = loaded.cfg.backfill.clone().unwrap_or_default();
147 let tz = match req.timezone.as_deref().or(spec.timezone.as_deref()) {
148 Some(name) => parse_timezone(name).map_err(|e| ServeError::BadConfig(e.to_string()))?,
149 None => chrono_tz::Tz::UTC,
150 };
151 let window = match req.window.as_deref().or(spec.window.as_deref()) {
152 Some(w) => Some(parse_window(w).map_err(|e| ServeError::BadConfig(e.to_string()))?),
153 None => None,
154 };
155 let from = parse_boundary(&req.from, tz).map_err(|e| ServeError::BadConfig(e.to_string()))?;
156 let to = parse_boundary(&req.to, tz).map_err(|e| ServeError::BadConfig(e.to_string()))?;
157 let units = crate::backfill::plan::plan_windows(from, to, window, tz)
158 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
159
160 let base_name = req
161 .name
162 .clone()
163 .or_else(|| loaded.cfg.name.clone())
164 .unwrap_or_else(|| "pipeline".to_string());
165 let descriptor = format!(
166 "time|{}|{}|{}|{base_name}",
167 from.to_rfc3339(),
168 to.to_rfc3339(),
169 window
170 .map(|w| w.to_string())
171 .unwrap_or_else(|| "whole".into()),
172 );
173 let hash = range_hash(&descriptor);
174
175 let doc: Value = serde_yaml::from_str(&req.config)
179 .map_err(|e| ServeError::BadConfig(format!("config is not valid YAML/JSON: {e}")))?;
180
181 crate::serve::audit::write(
182 &state,
183 &actor,
184 "backfill.submit",
185 None,
186 Some(hash.clone()),
187 "ok",
188 )
189 .await;
190
191 let planned = units.len();
192 let mut reports = Vec::with_capacity(planned);
193 let mut submitted = 0usize;
194 let mut queue_full = false;
195 for unit in units {
196 if queue_full {
197 reports.push(BackfillUnitRun {
198 unit: unit.id.clone(),
199 start: unit.start.to_rfc3339(),
200 end: unit.end.to_rfc3339(),
201 status: "not_submitted".into(),
202 run_id: None,
203 error: Some("run queue full — re-POST the same request to continue".into()),
204 });
205 continue;
206 }
207 let unit_name = format!("{base_name}-backfill-{}", unit.id);
208 let unit_doc = rewrite_unit_doc(&doc, &unit, &unit_name)
209 .map_err(|e| ServeError::BadConfig(e.to_string()))?;
210 let mut labels = req.labels.clone();
211 labels.insert("backfill".into(), hash.clone());
212 labels.insert("backfill_unit".into(), unit.id.clone());
213 let submit = SubmitRequest {
214 config: unit_doc,
215 config_format: ConfigFormatWire::Yaml,
216 name: Some(unit_name),
217 labels,
218 timeout_secs: req.timeout_secs,
219 doctor_first: false,
220 callback: None,
221 idempotency_key: Some(format!("backfill:{hash}:{}", unit.id)),
222 clock: Some(unit.start.to_rfc3339()),
223 };
224 match runner::submit(state.clone(), submit, actor.clone()).await {
225 Ok(resp) => {
226 submitted += 1;
227 reports.push(BackfillUnitRun {
228 unit: unit.id.clone(),
229 start: unit.start.to_rfc3339(),
230 end: unit.end.to_rfc3339(),
231 status: "submitted".into(),
232 run_id: Some(resp.run_id),
233 error: None,
234 });
235 }
236 Err(ServeError::QueueFull { .. }) => {
237 queue_full = true;
240 reports.push(BackfillUnitRun {
241 unit: unit.id.clone(),
242 start: unit.start.to_rfc3339(),
243 end: unit.end.to_rfc3339(),
244 status: "not_submitted".into(),
245 run_id: None,
246 error: Some("run queue full — re-POST the same request to continue".into()),
247 });
248 }
249 Err(other) => return Err(other),
250 }
251 }
252
253 Ok((
254 StatusCode::ACCEPTED,
255 Json(BackfillSubmitResponse {
256 backfill: hash,
257 descriptor,
258 planned,
259 submitted,
260 units: reports,
261 }),
262 ))
263}
264
265fn rewrite_unit_doc(
270 doc: &Value,
271 unit: &crate::backfill::plan::BackfillUnit,
272 unit_name: &str,
273) -> crate::error::CliResult<String> {
274 let mut d = doc.clone();
275 substitute_unit_tokens(&mut d, unit)?;
276 if let Some(map) = d.as_object_mut() {
277 map.insert("name".into(), Value::String(unit_name.to_string()));
278 map.insert("delivery".into(), Value::String("at_least_once".into()));
279 }
280 serde_yaml::to_string(&d)
281 .map_err(|e| crate::error::CliError::Internal(format!("unit config render: {e}")))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use serde_json::json;
288
289 #[test]
290 fn rewrite_substitutes_namespaces_and_forces_at_least_once() {
291 let utc: chrono_tz::Tz = "UTC".parse().unwrap();
292 let unit = crate::backfill::plan::BackfillUnit {
293 id: "20260601T000000Z".into(),
294 start: parse_boundary("2026-06-01T00:00:00Z", utc).unwrap(),
295 end: parse_boundary("2026-06-02T00:00:00Z", utc).unwrap(),
296 };
297 let doc = json!({
298 "version": 1,
299 "name": "orders",
300 "delivery": "exactly_once",
301 "pipeline": {
302 "source": {"type": "rest", "config": {"url": "https://x/o?s=${backfill.start}&e=${backfill.end}"}},
303 "sink": {"type": "jsonl", "config": {"path": "./out-${backfill.start_date}.jsonl"}}
304 }
305 });
306 let out = rewrite_unit_doc(&doc, &unit, "orders-backfill-20260601T000000Z").unwrap();
307 let back: Value = serde_yaml::from_str(&out).unwrap();
308 assert_eq!(back["name"], "orders-backfill-20260601T000000Z");
309 assert_eq!(back["delivery"], "at_least_once");
310 let url = back["pipeline"]["source"]["config"]["url"]
311 .as_str()
312 .unwrap();
313 assert!(url.contains("s=2026-06-01T00:00:00+00:00"), "{url}");
314 assert!(url.contains("e=2026-06-02T00:00:00+00:00"), "{url}");
315 assert_eq!(
316 back["pipeline"]["sink"]["config"]["path"],
317 "./out-2026-06-01.jsonl"
318 );
319 crate::config::parse_with_extension(&out, "yaml").expect("unit doc parses");
321 }
322
323 #[test]
324 fn rewrite_rejects_unknown_token() {
325 let utc: chrono_tz::Tz = "UTC".parse().unwrap();
326 let unit = crate::backfill::plan::BackfillUnit {
327 id: "u".into(),
328 start: parse_boundary("2026-06-01", utc).unwrap(),
329 end: parse_boundary("2026-06-02", utc).unwrap(),
330 };
331 let doc = json!({"pipeline": {"source": {"config": {"q": "${backfill.oops}"}}}});
332 assert!(rewrite_unit_doc(&doc, &unit, "n").is_err());
333 }
334}