Skip to main content

faucet_cli/
reconcile.rs

1//! Completeness reconciliation (#502).
2//!
3//! An opt-in post-run guard against **silent truncation**: after a successful
4//! root run, fetch an *authoritative* row count for the same data (a `count(*)`
5//! query, an OData `$count`, …) and compare it to the number of rows this run
6//! wrote. A shortfall beyond `tolerance_pct` **fails the run** — the point being
7//! that a half-read source must not quietly replace good data with less
8//! (especially under `write_mode: overwrite`, #492/#494).
9//!
10//! The authoritative count comes from a small **count-probe source** the user
11//! configures (any faucet source that yields the count as a single value/row),
12//! so reconciliation works for any backend without a per-connector `count()`
13//! capability. Pure evaluation lives in [`evaluate`] / [`extract_count`]; the
14//! CLI executor runs the probe post-run.
15
16use faucet_core::FaucetError;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21/// Top-level `reconcile:` block.
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct ReconcileSpec {
25    /// The authoritative-count probe: a source that returns the expected row
26    /// count (as a single record / value).
27    pub count: CountProbe,
28    /// Allowed shortfall, as a percentage of the authoritative count. `0.0`
29    /// (default) requires `written >= authoritative`. `1.0` tolerates up to a 1%
30    /// shortfall before failing.
31    #[serde(default)]
32    pub tolerance_pct: f64,
33}
34
35/// A source that yields the authoritative count.
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct CountProbe {
39    /// Connector type (e.g. `postgres`, `rest`).
40    #[serde(rename = "type")]
41    pub kind: String,
42    /// Connector-specific config (e.g. a `SELECT count(*) AS n …` query).
43    #[serde(default)]
44    pub config: Value,
45    /// Field in the probe's first record holding the count. When omitted, the
46    /// first numeric field of the first record is used (so a bare
47    /// `SELECT count(*)` works whatever the column is named).
48    #[serde(default)]
49    pub count_field: Option<String>,
50}
51
52impl ReconcileSpec {
53    /// Fail-fast validation: a non-empty probe `type` and a finite,
54    /// non-negative `tolerance_pct` in `[0, 100)`.
55    pub fn validate(&self) -> Result<(), FaucetError> {
56        if self.count.kind.trim().is_empty() {
57            return Err(FaucetError::Config(
58                "reconcile: `count.type` must name a connector".into(),
59            ));
60        }
61        if !self.tolerance_pct.is_finite() || !(0.0..100.0).contains(&self.tolerance_pct) {
62            return Err(FaucetError::Config(format!(
63                "reconcile: tolerance_pct must be in [0, 100), got {}",
64                self.tolerance_pct
65            )));
66        }
67        Ok(())
68    }
69}
70
71/// Extract the authoritative count from a probe's returned records. Uses the
72/// named `field` when given, else the first numeric field of the first record.
73/// Pure.
74pub fn extract_count(records: &[Value], field: Option<&str>) -> Result<u64, String> {
75    let first = records
76        .first()
77        .ok_or_else(|| "reconcile: the count probe returned no rows".to_string())?;
78    let as_u64 = |v: &Value| -> Option<u64> {
79        match v {
80            Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
81            Value::String(s) => s.trim().parse::<u64>().ok(),
82            _ => None,
83        }
84    };
85    match field {
86        Some(f) => {
87            let v = first.get(f).ok_or_else(|| {
88                format!("reconcile: count_field '{f}' not found in the probe row")
89            })?;
90            as_u64(v).ok_or_else(|| format!("reconcile: count_field '{f}' is not a number: {v}"))
91        }
92        None => first
93            .as_object()
94            .and_then(|m| m.values().find_map(as_u64))
95            // A bare scalar probe row (not an object) is also accepted.
96            .or_else(|| as_u64(first))
97            .ok_or_else(|| {
98                "reconcile: the count probe's first row has no numeric field".to_string()
99            }),
100    }
101}
102
103/// Compare rows written against the authoritative count. `Ok(())` when
104/// `written >= authoritative * (1 - tolerance_pct/100)`, else `Err` with a
105/// message naming the shortfall. Pure.
106pub fn evaluate(written: u64, authoritative: u64, tolerance_pct: f64) -> Result<(), String> {
107    let threshold = (authoritative as f64) * (1.0 - tolerance_pct / 100.0);
108    if (written as f64) + f64::EPSILON >= threshold {
109        return Ok(());
110    }
111    let shortfall = authoritative.saturating_sub(written);
112    Err(format!(
113        "completeness reconciliation failed: wrote {written} rows but the authoritative count is \
114         {authoritative} (short by {shortfall}; tolerance {tolerance_pct}%). Refusing to report a \
115         truncated run as successful."
116    ))
117}
118
119/// Run the count probe and reconcile it against `written`. Builds the probe
120/// source through the registry (so any connector works and shared `auth: { ref }`
121/// resolves), drains it, extracts the count, and evaluates. Returns
122/// `FaucetError::Source` on a shortfall so the caller fails the run.
123pub async fn run(
124    spec: &ReconcileSpec,
125    auth: &crate::auth_catalog::AuthCatalog,
126    written: u64,
127) -> Result<(), FaucetError> {
128    spec.validate()?;
129    let source =
130        crate::registry::build_source(&spec.count.kind, spec.count.config.clone(), auth, None)
131            .await
132            .map_err(|e| {
133                FaucetError::Source(format!("reconcile: building the count probe: {e}"))
134            })?;
135    let records = source.fetch_all().await?;
136    let authoritative =
137        extract_count(&records, spec.count.count_field.as_deref()).map_err(FaucetError::Source)?;
138    evaluate(written, authoritative, spec.tolerance_pct).map_err(FaucetError::Source)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn extract_named_field() {
148        let recs = vec![json!({"n": 42, "other": "x"})];
149        assert_eq!(extract_count(&recs, Some("n")).unwrap(), 42);
150    }
151
152    #[test]
153    fn extract_first_numeric_when_no_field() {
154        let recs = vec![json!({"label": "orders", "count": 7})];
155        assert_eq!(extract_count(&recs, None).unwrap(), 7);
156    }
157
158    #[test]
159    fn extract_string_number() {
160        let recs = vec![json!({"n": "100"})];
161        assert_eq!(extract_count(&recs, Some("n")).unwrap(), 100);
162    }
163
164    #[test]
165    fn extract_errors_on_empty_or_missing() {
166        assert!(extract_count(&[], None).is_err());
167        assert!(extract_count(&[json!({"a": "x"})], None).is_err());
168        assert!(extract_count(&[json!({"a": 1})], Some("b")).is_err());
169    }
170
171    #[test]
172    fn evaluate_passes_when_complete() {
173        assert!(evaluate(100, 100, 0.0).is_ok());
174        assert!(evaluate(101, 100, 0.0).is_ok());
175    }
176
177    #[test]
178    fn evaluate_fails_on_shortfall() {
179        let err = evaluate(90, 100, 0.0).unwrap_err();
180        assert!(err.contains("short by 10"), "{err}");
181    }
182
183    #[test]
184    fn evaluate_honors_tolerance() {
185        // 1% tolerance on 100 → threshold 99; 99 passes, 98 fails.
186        assert!(evaluate(99, 100, 1.0).is_ok());
187        assert!(evaluate(98, 100, 1.0).is_err());
188    }
189
190    #[cfg(feature = "source-csv")]
191    #[tokio::test]
192    async fn run_reconciles_against_a_count_probe() {
193        // A csv "count probe": one row holding the authoritative count.
194        let dir = tempfile::tempdir().unwrap();
195        let path = dir.path().join("count.csv");
196        std::fs::write(&path, "n\n5\n").unwrap();
197        let spec = ReconcileSpec {
198            count: CountProbe {
199                kind: "csv".into(),
200                config: json!({ "path": path.to_str().unwrap() }),
201                count_field: Some("n".into()),
202            },
203            tolerance_pct: 0.0,
204        };
205        let auth = crate::auth_catalog::AuthCatalog::new();
206        // Wrote >= 5 → complete.
207        assert!(run(&spec, &auth, 5).await.is_ok());
208        // Wrote < 5 → a shortfall fails the run.
209        let err = run(&spec, &auth, 3).await.unwrap_err();
210        assert!(err.to_string().contains("reconciliation failed"), "{err}");
211    }
212
213    #[test]
214    fn validate_rejects_bad_spec() {
215        let bad_tol = ReconcileSpec {
216            count: CountProbe {
217                kind: "postgres".into(),
218                config: json!({}),
219                count_field: None,
220            },
221            tolerance_pct: 150.0,
222        };
223        assert!(bad_tol.validate().is_err());
224        let empty_kind = ReconcileSpec {
225            count: CountProbe {
226                kind: "".into(),
227                config: json!({}),
228                count_field: None,
229            },
230            tolerance_pct: 0.0,
231        };
232        assert!(empty_kind.validate().is_err());
233    }
234}