Skip to main content

faucet_cli/partition/
probe.rs

1//! Discover a partition bound by running a source once (#479).
2//!
3//! An id range is frequently open-ended: you know where to start but not where
4//! the data ends. Rather than invent a probe protocol, the bound comes from an
5//! ordinary **source config** — so `SELECT MAX(id)`, a `?sort=-id&limit=1`
6//! request, or a count endpoint all work through the existing connector
7//! registry, auth catalog, and secrets. Discovery stays as source-agnostic as
8//! the substitution it feeds.
9//!
10//! ## The probe/plan race
11//!
12//! Rows inserted between the probe and the last chunk's execution sit above the
13//! discovered maximum and would never be read. `to_unbounded: true` closes that
14//! by dropping the final chunk's upper bound, so it is **defaulted on** whenever
15//! `to` is discovered — the same reason `plan_pk_shards` marks its last shard
16//! `hi_unbounded`. An explicit `to_unbounded: false` still wins, for a range the
17//! user knows is closed.
18//!
19//! ## Injection
20//!
21//! A probed value comes from outside faucet, so it is parsed into a typed `i64` /
22//! `u64` here and re-rendered from that. The raw string a source returned is
23//! never substituted into a config — which is what keeps the "every token value
24//! is faucet-generated" argument true once discovery is in play.
25
26use super::spec::{BoundProbe, CountBound, IntBound, PartitionSpec};
27use crate::auth_catalog::AuthCatalog;
28use crate::error::{CliError, CliResult};
29use serde_json::Value;
30
31/// Resolve every discoverable bound in `spec`, returning a spec whose bounds are
32/// literals. A spec with no probes is returned unchanged and costs nothing.
33pub async fn resolve_bounds(spec: &PartitionSpec, auth: &AuthCatalog) -> CliResult<PartitionSpec> {
34    Ok(match spec {
35        PartitionSpec::Integer {
36            from,
37            to,
38            chunk_size,
39            bounds,
40            to_unbounded,
41        } => match to {
42            IntBound::Literal(_) => spec.clone(),
43            IntBound::Discovered(p) => {
44                let raw = probe_value(p, auth).await?;
45                let v = as_i64(&raw, &p.value_path)?;
46                if v < *from {
47                    return Err(CliError::Config(format!(
48                        "partition: the discovered upper bound ({v}) is below `from` ({from}) — \
49                         the probe `{}` returned an empty or unexpected result",
50                        p.value_path
51                    )));
52                }
53                PartitionSpec::Integer {
54                    from: *from,
55                    to: IntBound::Literal(v),
56                    chunk_size: *chunk_size,
57                    bounds: *bounds,
58                    // Default the open-ended tail ON for a probed bound: the
59                    // probe is stale the instant it returns, so rows appended
60                    // between it and the last chunk would otherwise be missed.
61                    // An explicit setting still wins.
62                    to_unbounded: Some(to_unbounded.unwrap_or(true)),
63                }
64            }
65        },
66        PartitionSpec::Offset { total, chunk_size } => match total {
67            CountBound::Literal(_) => spec.clone(),
68            CountBound::Discovered(p) => {
69                let raw = probe_value(p, auth).await?;
70                let v = as_u64(&raw, &p.value_path)?;
71                PartitionSpec::Offset {
72                    total: CountBound::Literal(v),
73                    chunk_size: *chunk_size,
74                }
75            }
76        },
77        PartitionSpec::Timestamp { .. } => spec.clone(),
78    })
79}
80
81/// Whether `spec` needs a probe — lets `faucet validate` report that it cannot
82/// fully plan offline without pretending it can.
83pub fn needs_probe(spec: &PartitionSpec) -> bool {
84    matches!(
85        spec,
86        PartitionSpec::Integer {
87            to: IntBound::Discovered(_),
88            ..
89        } | PartitionSpec::Offset {
90            total: CountBound::Discovered(_),
91            ..
92        }
93    )
94}
95
96/// Whether a probed integer bound should default `to_unbounded` on.
97pub fn probe_implies_unbounded(spec: &PartitionSpec) -> bool {
98    matches!(
99        spec,
100        PartitionSpec::Integer {
101            to: IntBound::Discovered(_),
102            ..
103        }
104    )
105}
106
107/// Run the probe source and pull `value_path` out of its first record.
108async fn probe_value(p: &BoundProbe, auth: &AuthCatalog) -> CliResult<Value> {
109    let source = crate::registry::build_source(
110        &p.from_source.kind,
111        p.from_source.config.clone(),
112        auth,
113        None,
114    )
115    .await
116    .map_err(|e| {
117        CliError::Config(format!(
118            "partition bound probe: building source failed: {e}"
119        ))
120    })?;
121
122    let records = source.fetch_all().await.map_err(|e| {
123        CliError::Config(format!(
124            "partition bound probe: fetching the bound failed: {e}"
125        ))
126    })?;
127
128    // Empty is a distinct, actionable outcome — not "assume zero". Zero would
129    // silently plan nothing and the run would read no data at all.
130    let first = records.first().ok_or_else(|| {
131        CliError::Config(format!(
132            "partition bound probe: source '{}' returned no records, so the bound could not \
133             be determined. A `MAX(id)` over an empty table returns NULL rather than a row — \
134             give the range an explicit `to` if the source can legitimately be empty",
135            p.from_source.kind
136        ))
137    })?;
138
139    // Reuse core's JSONPath helper so the path grammar matches `records_path`
140    // and every other JSONPath surface in the project.
141    let found = faucet_core::util::extract_records(first, Some(&p.value_path)).map_err(|e| {
142        CliError::Config(format!(
143            "partition bound probe: value_path '{}' is not valid JSONPath: {e}",
144            p.value_path
145        ))
146    })?;
147    match found.as_slice() {
148        [] => Err(CliError::Config(format!(
149            "partition bound probe: value_path '{}' matched nothing in the probe's first \
150             record ({})",
151            p.value_path,
152            crate::secrets::registry::redact(&first.to_string())
153        ))),
154        [one] => Ok(one.clone()),
155        many => Err(CliError::Config(format!(
156            "partition bound probe: value_path '{}' matched {} values; it must select exactly \
157             one",
158            p.value_path,
159            many.len()
160        ))),
161    }
162}
163
164/// Parse a probed value as a signed bound. A JSON number or a numeric string are
165/// both accepted (a SQL driver may hand back either); anything else is an error
166/// rather than a silent zero.
167fn as_i64(v: &Value, path: &str) -> CliResult<i64> {
168    if v.is_null() {
169        return Err(CliError::Config(format!(
170            "partition bound probe: '{path}' is null — `MAX(id)` over an empty table returns \
171             NULL. Give the range an explicit `to`, or ensure the probe cannot match zero rows"
172        )));
173    }
174    v.as_i64()
175        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
176        .ok_or_else(|| {
177            CliError::Config(format!(
178                "partition bound probe: '{path}' is not an integer (got {v})"
179            ))
180        })
181}
182
183fn as_u64(v: &Value, path: &str) -> CliResult<u64> {
184    if v.is_null() {
185        return Err(CliError::Config(format!(
186            "partition bound probe: '{path}' is null — give the range an explicit `total`"
187        )));
188    }
189    v.as_u64()
190        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
191        .ok_or_else(|| {
192            CliError::Config(format!(
193                "partition bound probe: '{path}' is not a non-negative integer (got {v})"
194            ))
195        })
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde_json::json;
202
203    fn probe() -> BoundProbe {
204        BoundProbe {
205            from_source: crate::config::ConnectorSpec {
206                kind: "csv".into(),
207                config: json!({"path": "./x.csv"}),
208                transforms: None,
209                inherit_transforms: true,
210                status: None,
211                tags: Vec::new(),
212                complete_for: None,
213            },
214            value_path: "$.max_id".into(),
215        }
216    }
217
218    #[test]
219    fn needs_probe_only_when_a_bound_is_discovered() {
220        let literal = PartitionSpec::Integer {
221            from: 0,
222            to: IntBound::Literal(10),
223            chunk_size: 5,
224            bounds: crate::chunking::Bounds::Inclusive,
225            to_unbounded: None,
226        };
227        assert!(!needs_probe(&literal));
228
229        let discovered = PartitionSpec::Integer {
230            from: 0,
231            to: IntBound::Discovered(probe()),
232            chunk_size: 5,
233            bounds: crate::chunking::Bounds::Inclusive,
234            to_unbounded: None,
235        };
236        assert!(needs_probe(&discovered));
237        assert!(probe_implies_unbounded(&discovered));
238        assert!(!probe_implies_unbounded(&literal));
239
240        // A timestamp range is never probed.
241        assert!(!needs_probe(&PartitionSpec::Timestamp {
242            from: "a".into(),
243            to: "b".into(),
244            chunk_size: "1d".into(),
245            timezone: None,
246        }));
247    }
248
249    #[test]
250    fn parses_numbers_and_numeric_strings() {
251        assert_eq!(as_i64(&json!(42), "$.x").unwrap(), 42);
252        // A SQL driver may hand back a big integer as text.
253        assert_eq!(as_i64(&json!("  42 "), "$.x").unwrap(), 42);
254        assert_eq!(as_u64(&json!(7), "$.x").unwrap(), 7);
255        assert_eq!(as_u64(&json!("7"), "$.x").unwrap(), 7);
256    }
257
258    #[test]
259    fn null_is_refused_with_the_empty_table_explanation() {
260        // `MAX(id)` over an empty table returns NULL. Treating that as 0 would
261        // plan a single degenerate chunk and read nothing.
262        let err = as_i64(&json!(null), "$.max_id").unwrap_err().to_string();
263        assert!(err.contains("null"), "{err}");
264        assert!(err.contains("empty table"), "explains why: {err}");
265        assert!(as_u64(&json!(null), "$.total").is_err());
266    }
267
268    #[test]
269    fn non_numeric_is_refused_rather_than_defaulted() {
270        for v in [json!("abc"), json!(true), json!({"a": 1}), json!([1])] {
271            assert!(as_i64(&v, "$.x").is_err(), "{v} must not parse");
272            assert!(as_u64(&v, "$.x").is_err(), "{v} must not parse");
273        }
274        // Negative is a valid i64 bound but never a valid count.
275        assert_eq!(as_i64(&json!(-5), "$.x").unwrap(), -5);
276        assert!(as_u64(&json!(-5), "$.x").is_err());
277    }
278
279    #[tokio::test]
280    async fn a_probe_below_from_is_refused() {
281        // Guards the "probe returned something unexpected" case: planning
282        // [0, -1] would otherwise be an opaque empty-range error.
283        let dir = tempfile::tempdir().unwrap();
284        let csv = dir.path().join("p.csv");
285        std::fs::write(&csv, "max_id\n-3\n").unwrap();
286        let mut p = probe();
287        p.from_source.config = json!({ "path": csv.to_str().unwrap() });
288
289        let spec = PartitionSpec::Integer {
290            from: 0,
291            to: IntBound::Discovered(p),
292            chunk_size: 5,
293            bounds: crate::chunking::Bounds::Inclusive,
294            to_unbounded: None,
295        };
296        let err = resolve_bounds(&spec, &AuthCatalog::default())
297            .await
298            .unwrap_err()
299            .to_string();
300        assert!(err.contains("below `from`"), "{err}");
301    }
302
303    #[tokio::test]
304    async fn a_probed_bound_defaults_the_open_tail_on() {
305        // The probe is stale the instant it returns, so rows appended between it
306        // and the last chunk would be missed without an open final chunk.
307        let dir = tempfile::tempdir().unwrap();
308        let csv = dir.path().join("p.csv");
309        std::fs::write(&csv, "max_id\n99\n").unwrap();
310        let mut p = probe();
311        p.from_source.config = json!({ "path": csv.to_str().unwrap() });
312
313        let mk = |explicit: Option<bool>| PartitionSpec::Integer {
314            from: 0,
315            to: IntBound::Discovered(p.clone()),
316            chunk_size: 50,
317            bounds: crate::chunking::Bounds::Inclusive,
318            to_unbounded: explicit,
319        };
320        let unset = resolve_bounds(&mk(None), &AuthCatalog::default())
321            .await
322            .unwrap();
323        match unset {
324            PartitionSpec::Integer { to_unbounded, .. } => {
325                assert_eq!(
326                    to_unbounded,
327                    Some(true),
328                    "unset must default ON when probed"
329                )
330            }
331            o => panic!("{o:?}"),
332        }
333        // An explicit `false` still wins — the user knows the range is closed.
334        let forced = resolve_bounds(&mk(Some(false)), &AuthCatalog::default())
335            .await
336            .unwrap();
337        match forced {
338            PartitionSpec::Integer { to_unbounded, .. } => assert_eq!(to_unbounded, Some(false)),
339            o => panic!("{o:?}"),
340        }
341    }
342
343    #[tokio::test]
344    async fn a_literal_bound_never_defaults_the_open_tail_on() {
345        let spec = PartitionSpec::Integer {
346            from: 0,
347            to: IntBound::Literal(10),
348            chunk_size: 5,
349            bounds: crate::chunking::Bounds::Inclusive,
350            to_unbounded: None,
351        };
352        let out = resolve_bounds(&spec, &AuthCatalog::default())
353            .await
354            .unwrap();
355        match out {
356            PartitionSpec::Integer { to_unbounded, .. } => assert_eq!(to_unbounded, None),
357            o => panic!("{o:?}"),
358        }
359    }
360
361    #[tokio::test]
362    async fn a_literal_spec_is_returned_unchanged_without_any_probing() {
363        let spec = PartitionSpec::Offset {
364            total: CountBound::Literal(10),
365            chunk_size: 5,
366        };
367        let out = resolve_bounds(&spec, &AuthCatalog::default())
368            .await
369            .unwrap();
370        assert_eq!(out, spec);
371    }
372
373    #[tokio::test]
374    async fn a_discovered_bound_is_resolved_from_the_probe_source() {
375        let dir = tempfile::tempdir().unwrap();
376        let csv = dir.path().join("p.csv");
377        std::fs::write(&csv, "max_id\n99\n").unwrap();
378        let mut p = probe();
379        p.from_source.config = json!({ "path": csv.to_str().unwrap() });
380
381        let spec = PartitionSpec::Integer {
382            from: 0,
383            to: IntBound::Discovered(p),
384            chunk_size: 50,
385            bounds: crate::chunking::Bounds::Inclusive,
386            to_unbounded: Some(true),
387        };
388        let out = resolve_bounds(&spec, &AuthCatalog::default())
389            .await
390            .unwrap();
391        match out {
392            PartitionSpec::Integer { to, .. } => assert_eq!(to, IntBound::Literal(99)),
393            other => panic!("expected an integer spec, got {other:?}"),
394        }
395    }
396
397    #[tokio::test]
398    async fn an_empty_probe_result_is_actionable_not_zero() {
399        let dir = tempfile::tempdir().unwrap();
400        let csv = dir.path().join("p.csv");
401        std::fs::write(&csv, "max_id\n").unwrap(); // header only
402        let mut p = probe();
403        p.from_source.config = json!({ "path": csv.to_str().unwrap() });
404
405        let spec = PartitionSpec::Integer {
406            from: 0,
407            to: IntBound::Discovered(p),
408            chunk_size: 5,
409            bounds: crate::chunking::Bounds::Inclusive,
410            to_unbounded: None,
411        };
412        let err = resolve_bounds(&spec, &AuthCatalog::default())
413            .await
414            .unwrap_err()
415            .to_string();
416        assert!(err.contains("no records"), "{err}");
417        assert!(err.contains("explicit `to`"), "suggests the fix: {err}");
418    }
419}