Skip to main content

faucet_cli/partition/
plan.rs

1//! Turn a [`PartitionSpec`] into concrete chunks and
2//! substitute their `${partition.*}` tokens into connector configs (#479).
3//!
4//! Substitution walks every string leaf of a config `Value`, exactly like
5//! `backfill::plan::substitute_unit_tokens` — which is why this works for any
6//! source without a line of connector code. A REST URL, a SQL `WHERE`, an object
7//! prefix and a Mongo filter are all just strings.
8//!
9//! ## Injection
10//!
11//! Tokens land inside SQL and JSON strings, where `substitute_context` is
12//! documented as unsafe. What makes it safe here is that every rendered value is
13//! **faucet-generated and type-constrained** — an `i64`, a `u64`, or an RFC3339
14//! timestamp formatted by chrono — never user data and never a passthrough of
15//! anything a remote system said. [`plan()`] is the only place token values are
16//! produced, and it produces them from typed fields — an `i64` start/end, a
17//! `u64` offset/limit, a chrono-formatted timestamp.
18
19use super::spec::{CountBound, IntBound, PartitionSpec};
20use crate::chunking::{self, Bounds};
21use crate::error::{CliError, CliResult};
22use serde_json::Value;
23use std::collections::BTreeMap;
24
25/// The literal prefix a partition token starts with.
26const PREFIX: &str = "${partition.";
27
28/// One planned chunk: a stable id plus the tokens its invocation substitutes.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PartitionChunk {
31    /// Stable id, used as the state-key suffix and in log lines. Zero-padded so
32    /// chunk ids sort in plan order.
33    pub id: String,
34    /// Token name → rendered value, e.g. `start` → `"10000"`.
35    pub tokens: BTreeMap<String, String>,
36    /// True for the final chunk of an `to_unbounded` integer range: its upper
37    /// bound is omitted so late-arriving rows above the planned maximum are
38    /// still read.
39    pub open_ended: bool,
40}
41
42/// Plan every chunk for `spec`.
43pub fn plan(spec: &PartitionSpec) -> CliResult<Vec<PartitionChunk>> {
44    spec.validate()?;
45    match spec {
46        PartitionSpec::Integer {
47            from,
48            to,
49            chunk_size,
50            bounds,
51            to_unbounded,
52        } => {
53            let to = match to {
54                IntBound::Literal(v) => *v,
55                IntBound::Discovered(_) => {
56                    return Err(CliError::Internal(
57                        "partition: an undiscovered bound reached the planner —                          `resolve_bounds` must run before `plan`"
58                            .into(),
59                    ));
60                }
61            };
62            let chunks = chunking::plan_int_chunks(*from, to, *chunk_size, *bounds)?;
63            Ok(chunks
64                .into_iter()
65                .enumerate()
66                .map(|(i, c)| {
67                    let open = to_unbounded.unwrap_or(false) && c.is_last;
68                    let mut tokens = BTreeMap::new();
69                    tokens.insert("start".into(), c.start.to_string());
70                    tokens.insert("end".into(), c.end.to_string());
71                    tokens.insert("index".into(), i.to_string());
72                    tokens.insert("id".into(), c.id.clone());
73                    PartitionChunk {
74                        id: c.id,
75                        tokens,
76                        open_ended: open,
77                    }
78                })
79                .collect())
80        }
81
82        PartitionSpec::Timestamp {
83            from,
84            to,
85            chunk_size,
86            timezone,
87        } => {
88            let tz: chrono_tz::Tz = timezone
89                .as_deref()
90                .unwrap_or("UTC")
91                .parse()
92                .map_err(|_| CliError::Config("invalid partition.timezone".into()))?;
93            let step = chunking::parse_window(chunk_size)?;
94            let from = chunking::parse_boundary(from, tz)?;
95            let to = chunking::parse_boundary(to, tz)?;
96            let chunks = chunking::plan_windows(from, to, Some(step), tz)?;
97            Ok(chunks
98                .into_iter()
99                .enumerate()
100                .map(|(i, c)| {
101                    let mut tokens = BTreeMap::new();
102                    tokens.insert("start".into(), c.start.to_rfc3339());
103                    tokens.insert("end".into(), c.end.to_rfc3339());
104                    tokens.insert("start_date".into(), c.start.format("%Y-%m-%d").to_string());
105                    tokens.insert("end_date".into(), c.end.format("%Y-%m-%d").to_string());
106                    tokens.insert("start_unix".into(), c.start.timestamp().to_string());
107                    tokens.insert("end_unix".into(), c.end.timestamp().to_string());
108                    tokens.insert("index".into(), i.to_string());
109                    tokens.insert("id".into(), c.id.clone());
110                    PartitionChunk {
111                        id: c.id,
112                        tokens,
113                        open_ended: false,
114                    }
115                })
116                .collect())
117        }
118
119        PartitionSpec::Offset { total, chunk_size } => {
120            let total = match total {
121                CountBound::Literal(v) => *v,
122                CountBound::Discovered(_) => {
123                    return Err(CliError::Internal(
124                        "partition: an undiscovered total reached the planner —                          `resolve_bounds` must run before `plan`"
125                            .into(),
126                    ));
127                }
128            };
129            let chunks = chunking::plan_offset_chunks(total, *chunk_size)?;
130            Ok(chunks
131                .into_iter()
132                .enumerate()
133                .map(|(i, c)| {
134                    let mut tokens = BTreeMap::new();
135                    tokens.insert("offset".into(), c.offset.to_string());
136                    tokens.insert("limit".into(), c.limit.to_string());
137                    tokens.insert("index".into(), i.to_string());
138                    tokens.insert("id".into(), c.id.clone());
139                    PartitionChunk {
140                        id: c.id,
141                        tokens,
142                        open_ended: false,
143                    }
144                })
145                .collect())
146        }
147    }
148}
149
150/// Substitute `${partition.*}` tokens in every string leaf of `value`.
151///
152/// An unrecognized token is a typo, not a passthrough: it errors, naming the
153/// tokens this chunk actually defines. Silently leaving `${partition.strat}` in a
154/// URL would send the literal text to the source.
155pub fn substitute(value: &mut Value, chunk: &PartitionChunk) -> CliResult<()> {
156    match value {
157        Value::String(s) => {
158            *s = substitute_in_str(s, chunk)?;
159            Ok(())
160        }
161        Value::Array(a) => a.iter_mut().try_for_each(|v| substitute(v, chunk)),
162        Value::Object(m) => m.values_mut().try_for_each(|v| substitute(v, chunk)),
163        _ => Ok(()),
164    }
165}
166
167fn substitute_in_str(input: &str, chunk: &PartitionChunk) -> CliResult<String> {
168    let mut out = String::with_capacity(input.len());
169    let mut rest = input;
170    while let Some(pos) = rest.find(PREFIX) {
171        out.push_str(&rest[..pos]);
172        let after = &rest[pos + PREFIX.len()..];
173        let close = after.find('}').ok_or_else(|| {
174            CliError::Config(format!("unterminated ${{partition.…}} token in '{input}'"))
175        })?;
176        let token = &after[..close];
177        let rendered = chunk.tokens.get(token).ok_or_else(|| {
178            CliError::Config(format!(
179                "unknown token ${{partition.{token}}} — this partition defines: {}",
180                chunk.tokens.keys().cloned().collect::<Vec<_>>().join(", ")
181            ))
182        })?;
183        out.push_str(rendered);
184        rest = &after[close + 1..];
185    }
186    out.push_str(rest);
187    Ok(out)
188}
189
190/// Whether a serialized config references any `${partition.*}` token.
191///
192/// A `partition:` block on a row whose source ignores the tokens would run the
193/// same query N times, so this gates the config at load time.
194pub fn references_partition(serialized: &str) -> bool {
195    serialized.contains(PREFIX)
196}
197
198/// The `end` predicate for an open-ended final chunk is the caller's problem —
199/// this reports whether one was planned so `expand` can require the config to
200/// handle it.
201pub fn has_open_ended(chunks: &[PartitionChunk]) -> bool {
202    chunks.iter().any(|c| c.open_ended)
203}
204
205/// Bounds label for log lines.
206pub fn bounds_label(b: Bounds) -> &'static str {
207    match b {
208        Bounds::Inclusive => "inclusive",
209        Bounds::HalfOpen => "half_open",
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    fn int_spec(to_unbounded: bool) -> PartitionSpec {
219        PartitionSpec::Integer {
220            from: 0,
221            to: IntBound::Literal(24),
222            chunk_size: 10,
223            bounds: Bounds::Inclusive,
224            to_unbounded: to_unbounded.then_some(true),
225        }
226    }
227
228    #[test]
229    fn integer_chunks_carry_start_end_index_id() {
230        let chunks = plan(&int_spec(false)).unwrap();
231        assert_eq!(chunks.len(), 3);
232        assert_eq!(chunks[0].tokens["start"], "0");
233        assert_eq!(chunks[0].tokens["end"], "9");
234        assert_eq!(chunks[0].tokens["index"], "0");
235        assert_eq!(
236            chunks[2].tokens["end"], "24",
237            "final chunk truncates at `to`"
238        );
239        assert!(chunks.iter().all(|c| !c.open_ended));
240    }
241
242    #[test]
243    fn only_the_final_chunk_is_open_ended_and_only_when_asked() {
244        let chunks = plan(&int_spec(true)).unwrap();
245        assert_eq!(chunks.iter().filter(|c| c.open_ended).count(), 1);
246        assert!(chunks.last().unwrap().open_ended);
247        assert!(has_open_ended(&chunks));
248        assert!(!has_open_ended(&plan(&int_spec(false)).unwrap()));
249    }
250
251    #[test]
252    fn offset_chunks_carry_offset_and_limit() {
253        let chunks = plan(&PartitionSpec::Offset {
254            total: CountBound::Literal(25),
255            chunk_size: 10,
256        })
257        .unwrap();
258        assert_eq!(chunks.len(), 3);
259        assert_eq!(chunks[0].tokens["offset"], "0");
260        assert_eq!(chunks[0].tokens["limit"], "10");
261        assert_eq!(
262            chunks[2].tokens["limit"], "5",
263            "final limit is the remainder"
264        );
265        assert!(
266            !chunks[0].tokens.contains_key("start"),
267            "no id-range tokens"
268        );
269    }
270
271    #[test]
272    fn timestamp_chunks_carry_the_backfill_token_set() {
273        let chunks = plan(&PartitionSpec::Timestamp {
274            from: "2026-06-01".into(),
275            to: "2026-06-04".into(),
276            chunk_size: "1d".into(),
277            timezone: None,
278        })
279        .unwrap();
280        assert_eq!(chunks.len(), 3);
281        for t in [
282            "start",
283            "end",
284            "start_date",
285            "end_date",
286            "start_unix",
287            "end_unix",
288        ] {
289            assert!(chunks[0].tokens.contains_key(t), "missing {t}");
290        }
291        assert_eq!(chunks[0].tokens["start_date"], "2026-06-01");
292        assert_eq!(chunks[1].tokens["start_date"], "2026-06-02");
293    }
294
295    #[test]
296    fn substitutes_into_nested_string_leaves_only() {
297        let chunks = plan(&int_spec(false)).unwrap();
298        let mut cfg = json!({
299            "url": "https://api/x?from=${partition.start}&to=${partition.end}",
300            "nested": { "list": ["chunk-${partition.index}", 7, true] },
301            "count": 3
302        });
303        substitute(&mut cfg, &chunks[1]).unwrap();
304        assert_eq!(cfg["url"], "https://api/x?from=10&to=19");
305        assert_eq!(cfg["nested"]["list"][0], "chunk-1");
306        assert_eq!(cfg["nested"]["list"][1], 7, "non-strings untouched");
307        assert_eq!(cfg["count"], 3);
308    }
309
310    #[test]
311    fn substitutes_the_same_token_more_than_once() {
312        let chunks = plan(&int_spec(false)).unwrap();
313        let mut cfg = json!({ "q": "id >= ${partition.start} AND ${partition.start} > 0" });
314        substitute(&mut cfg, &chunks[0]).unwrap();
315        assert_eq!(cfg["q"], "id >= 0 AND 0 > 0");
316    }
317
318    #[test]
319    fn an_unknown_token_errors_and_lists_what_is_available() {
320        let chunks = plan(&int_spec(false)).unwrap();
321        let mut cfg = json!({ "url": "x?a=${partition.strat}" });
322        let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
323        assert!(err.contains("strat"), "{err}");
324        assert!(err.contains("start"), "should list the real tokens: {err}");
325    }
326
327    #[test]
328    fn an_offset_config_cannot_reference_id_range_tokens() {
329        // Guards the kind/token mismatch: a config written for an id range but
330        // partitioned by offset fails loudly instead of sending literal text.
331        let chunks = plan(&PartitionSpec::Offset {
332            total: CountBound::Literal(10),
333            chunk_size: 5,
334        })
335        .unwrap();
336        let mut cfg = json!({ "url": "x?from=${partition.start}" });
337        let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
338        assert!(err.contains("start"), "{err}");
339        assert!(err.contains("offset"), "lists the offset tokens: {err}");
340    }
341
342    #[test]
343    fn an_unterminated_token_is_a_typed_error() {
344        let chunks = plan(&int_spec(false)).unwrap();
345        let mut cfg = json!({ "url": "x?a=${partition.start" });
346        let err = substitute(&mut cfg, &chunks[0]).unwrap_err().to_string();
347        assert!(err.contains("unterminated"), "{err}");
348    }
349
350    #[test]
351    fn detects_whether_a_config_references_the_tokens() {
352        assert!(references_partition(r#"{"url":"x?a=${partition.start}"}"#));
353        assert!(!references_partition(r#"{"url":"x?a=${now.date}"}"#));
354    }
355
356    #[test]
357    fn rendered_values_are_numeric_or_rfc3339_never_passthrough() {
358        // The injection-safety argument: every value comes from a typed field.
359        let chunks = plan(&int_spec(false)).unwrap();
360        for c in &chunks {
361            for k in ["start", "end", "index"] {
362                assert!(
363                    c.tokens[k].parse::<i64>().is_ok(),
364                    "{k} must render as an integer, got {:?}",
365                    c.tokens[k]
366                );
367            }
368        }
369        let ts = plan(&PartitionSpec::Timestamp {
370            from: "2026-06-01".into(),
371            to: "2026-06-02".into(),
372            chunk_size: "1d".into(),
373            timezone: None,
374        })
375        .unwrap();
376        assert!(chrono::DateTime::parse_from_rfc3339(&ts[0].tokens["start"]).is_ok());
377    }
378}