Skip to main content

faucet_core/
replication.rs

1//! Incremental replication support.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::cmp::Ordering;
7
8/// Determines how records are replicated from the source.
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
10#[serde(tag = "type")]
11pub enum ReplicationMethod {
12    /// All records are fetched on every run (default).
13    #[default]
14    FullTable,
15    /// Only records where the `replication_key` field is strictly greater than
16    /// the stored bookmark (`start_replication_value`) are kept.
17    Incremental,
18}
19
20/// Filter `records` to only those where `record[key] > start`.
21///
22/// Records missing the key are excluded. Strings compare lexicographically
23/// (ISO-8601 dates compare correctly this way); integers compare exactly
24/// (no `f64` precision loss); floats compare as `f64`.
25///
26/// If a record's key value is a *different JSON type* than `start` (e.g. a
27/// numeric key against a string bookmark), the comparison is not meaningful;
28/// rather than silently dropping the record — which is data loss (#78/#27) —
29/// it is **kept** and a warning is logged.
30pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
31    records
32        .into_iter()
33        .filter(|r| match r.get(key) {
34            None => false,
35            Some(v) if type_rank(v) != type_rank(start) => {
36                tracing::warn!(
37                    key,
38                    "incremental replication: record key type does not match the bookmark \
39                     type; keeping the record to avoid silently dropping data"
40                );
41                true
42            }
43            Some(v) => json_gt(v, start),
44        })
45        .collect()
46}
47
48/// Return the maximum value of `record[key]` across all records, if any.
49pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
50    records
51        .iter()
52        .filter_map(|r| r.get(key))
53        .max_by(|a, b| json_compare(a, b))
54}
55
56/// Return the larger of two replication values using the same ordering as
57/// [`max_replication_value`] (string lexicographic, numeric for numbers,
58/// falling back to `a` on type mismatch).
59pub fn max_value(a: Value, b: Value) -> Value {
60    match json_compare(&a, &b) {
61        Ordering::Less => b,
62        _ => a,
63    }
64}
65
66/// Type-rank for a total ordering across JSON value kinds, so comparisons of
67/// differing types are deterministic instead of collapsing to `Equal`.
68fn type_rank(v: &Value) -> u8 {
69    match v {
70        Value::Null => 0,
71        Value::Bool(_) => 1,
72        Value::Number(_) => 2,
73        Value::String(_) => 3,
74        Value::Array(_) => 4,
75        Value::Object(_) => 5,
76    }
77}
78
79/// Exact integer view of a JSON number (`i64` or `u64`), widened to `i128` so
80/// both halves of the range compare without `f64` precision loss. `None` for
81/// non-integral (floating) numbers.
82fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
83    n.as_i64()
84        .map(i128::from)
85        .or_else(|| n.as_u64().map(i128::from))
86}
87
88/// Total ordering over JSON values used for replication bookmarks.
89///
90/// - Numbers: compared exactly as `i128` when both are integral (so cursors
91///   above 2^53 don't lose precision); otherwise as `f64`, with NaN ordered
92///   last.
93/// - Same-type scalars/containers: natural ordering (strings lexicographic,
94///   bools `false < true`, arrays element-wise, objects by serialized form).
95/// - Different types: ordered by [`type_rank`] so the result is always total.
96pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
97    match (a, b) {
98        (Value::Number(an), Value::Number(bn)) => {
99            match (number_as_i128(an), number_as_i128(bn)) {
100                (Some(ai), Some(bi)) => ai.cmp(&bi),
101                _ => {
102                    let af = an.as_f64().unwrap_or(f64::NAN);
103                    let bf = bn.as_f64().unwrap_or(f64::NAN);
104                    af.partial_cmp(&bf).unwrap_or_else(|| {
105                        // At least one NaN — order NaN last, deterministically.
106                        match (af.is_nan(), bf.is_nan()) {
107                            (false, true) => Ordering::Less,
108                            (true, false) => Ordering::Greater,
109                            _ => Ordering::Equal,
110                        }
111                    })
112                }
113            }
114        }
115        (Value::String(x), Value::String(y)) => x.cmp(y),
116        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
117        (Value::Null, Value::Null) => Ordering::Equal,
118        (Value::Array(x), Value::Array(y)) => {
119            for (xi, yi) in x.iter().zip(y.iter()) {
120                let c = json_compare(xi, yi);
121                if c != Ordering::Equal {
122                    return c;
123                }
124            }
125            x.len().cmp(&y.len())
126        }
127        // Objects have no natural order; use the serialized form for a stable
128        // total order (objects as replication keys are pathological).
129        (Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
130        // Different JSON types — order by type rank so comparison is total.
131        _ => type_rank(a).cmp(&type_rank(b)),
132    }
133}
134
135/// Total-order "greater than" over JSON values, using the same comparison
136/// [`filter_incremental`] applies to replication keys (numbers numerically,
137/// strings lexicographically — so RFC3339 timestamps order correctly). Public
138/// so callers bounding a replay window (e.g. `faucet backfill --to-bookmark`)
139/// compare exactly like the incremental filter does.
140pub fn json_gt(a: &Value, b: &Value) -> bool {
141    json_compare(a, b) == Ordering::Greater
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    #[test]
150    fn test_filter_incremental_strings() {
151        let records = vec![
152            json!({"id": 1, "updated_at": "2024-01-01"}),
153            json!({"id": 2, "updated_at": "2024-06-01"}),
154            json!({"id": 3, "updated_at": "2024-12-01"}),
155        ];
156        let start = json!("2024-06-01");
157        let filtered = filter_incremental(records, "updated_at", &start);
158        assert_eq!(filtered.len(), 1);
159        assert_eq!(filtered[0]["id"], 3);
160    }
161
162    #[test]
163    fn test_filter_incremental_numbers() {
164        let records = vec![
165            json!({"id": 1, "seq": 100}),
166            json!({"id": 2, "seq": 200}),
167            json!({"id": 3, "seq": 300}),
168        ];
169        let start = json!(150);
170        let filtered = filter_incremental(records, "seq", &start);
171        assert_eq!(filtered.len(), 2);
172        assert_eq!(filtered[0]["id"], 2);
173        assert_eq!(filtered[1]["id"], 3);
174    }
175
176    #[test]
177    fn test_filter_incremental_missing_key_excluded() {
178        let records = vec![
179            json!({"id": 1}),
180            json!({"id": 2, "updated_at": "2024-12-01"}),
181        ];
182        let start = json!("2024-01-01");
183        let filtered = filter_incremental(records, "updated_at", &start);
184        assert_eq!(filtered.len(), 1);
185        assert_eq!(filtered[0]["id"], 2);
186    }
187
188    #[test]
189    fn test_filter_incremental_equal_excluded() {
190        let records = vec![
191            json!({"id": 1, "updated_at": "2024-06-01"}),
192            json!({"id": 2, "updated_at": "2024-06-02"}),
193        ];
194        let start = json!("2024-06-01");
195        let filtered = filter_incremental(records, "updated_at", &start);
196        assert_eq!(filtered.len(), 1);
197        assert_eq!(filtered[0]["id"], 2);
198    }
199
200    #[test]
201    fn test_max_replication_value_strings() {
202        let records = vec![
203            json!({"updated_at": "2024-01-01"}),
204            json!({"updated_at": "2024-12-01"}),
205            json!({"updated_at": "2024-06-01"}),
206        ];
207        let max = max_replication_value(&records, "updated_at").unwrap();
208        assert_eq!(max, &json!("2024-12-01"));
209    }
210
211    #[test]
212    fn test_max_replication_value_numbers() {
213        let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
214        let max = max_replication_value(&records, "seq").unwrap();
215        assert_eq!(max, &json!(10));
216    }
217
218    #[test]
219    fn test_max_replication_value_empty() {
220        let records: Vec<Value> = vec![];
221        assert!(max_replication_value(&records, "updated_at").is_none());
222    }
223
224    #[test]
225    fn test_max_value_picks_larger_string() {
226        assert_eq!(
227            max_value(json!("2024-01-01"), json!("2024-06-01")),
228            json!("2024-06-01")
229        );
230    }
231
232    #[test]
233    fn test_max_value_picks_larger_number() {
234        assert_eq!(max_value(json!(5), json!(10)), json!(10));
235    }
236
237    #[test]
238    fn test_max_value_returns_a_on_type_mismatch() {
239        // String outranks Number in the total type-rank ordering, so the
240        // larger (a) is returned.
241        assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
242    }
243
244    #[test]
245    fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
246        // Regression for #78/#27: integer cursors above 2^53 lose precision
247        // when compared as f64, so a genuinely-greater value compared Equal
248        // and was silently dropped.
249        let two_pow_53 = 9_007_199_254_740_992_i64; // 2^53
250        let records = vec![
251            json!({"id": 1, "seq": two_pow_53 + 1}),
252            json!({"id": 2, "seq": two_pow_53 + 2}),
253        ];
254        let start = json!(two_pow_53);
255        let filtered = filter_incremental(records, "seq", &start);
256        assert_eq!(
257            filtered.len(),
258            2,
259            "both values are strictly greater than 2^53"
260        );
261    }
262
263    #[test]
264    fn json_compare_distinguishes_large_integers() {
265        let a = json!(9_007_199_254_740_993_i64); // 2^53 + 1
266        let b = json!(9_007_199_254_740_992_i64); // 2^53
267        assert_eq!(json_compare(&a, &b), Ordering::Greater);
268    }
269
270    #[test]
271    fn filter_incremental_keeps_records_on_type_mismatch() {
272        // Regression for #78/#27: a bookmark/key type mismatch must not be
273        // silently treated as "not greater" and the record dropped — that is
274        // data loss. Keep the record instead.
275        let records = vec![json!({"id": 1, "seq": 20_240_701})];
276        let start = json!("2024-06-01"); // string bookmark vs numeric key
277        let filtered = filter_incremental(records, "seq", &start);
278        assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
279    }
280}