Skip to main content

faucet_core/
replication.rs

1//! Incremental replication support.
2
3use crate::error::FaucetError;
4use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::cmp::Ordering;
9
10/// Determines how records are replicated from the source.
11#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "type")]
13pub enum ReplicationMethod {
14    /// All records are fetched on every run (default).
15    #[default]
16    FullTable,
17    /// Only records where the `replication_key` field is strictly greater than
18    /// the stored bookmark (`start_replication_value`) are kept.
19    Incremental,
20}
21
22/// Filter `records` to only those where `record[key] > start`.
23///
24/// Records missing the key are excluded. Strings compare lexicographically
25/// (ISO-8601 dates compare correctly this way); integers compare exactly
26/// (no `f64` precision loss); floats compare as `f64`.
27///
28/// If a record's key value is a *different JSON type* than `start` (e.g. a
29/// numeric key against a string bookmark), the comparison is not meaningful;
30/// rather than silently dropping the record — which is data loss (#78/#27) —
31/// it is **kept** and a warning is logged.
32pub fn filter_incremental(records: Vec<Value>, key: &str, start: &Value) -> Vec<Value> {
33    records
34        .into_iter()
35        .filter(|r| match r.get(key) {
36            None => false,
37            Some(v) if type_rank(v) != type_rank(start) => {
38                tracing::warn!(
39                    key,
40                    "incremental replication: record key type does not match the bookmark \
41                     type; keeping the record to avoid silently dropping data"
42                );
43                true
44            }
45            Some(v) => json_gt(v, start),
46        })
47        .collect()
48}
49
50/// Return the maximum value of `record[key]` across all records, if any.
51pub fn max_replication_value<'a>(records: &'a [Value], key: &str) -> Option<&'a Value> {
52    records
53        .iter()
54        .filter_map(|r| r.get(key))
55        .max_by(|a, b| json_compare(a, b))
56}
57
58/// Return the larger of two replication values using the same ordering as
59/// [`max_replication_value`] (string lexicographic, numeric for numbers,
60/// falling back to `a` on type mismatch).
61pub fn max_value(a: Value, b: Value) -> Value {
62    match json_compare(&a, &b) {
63        Ordering::Less => b,
64        _ => a,
65    }
66}
67
68/// Type-rank for a total ordering across JSON value kinds, so comparisons of
69/// differing types are deterministic instead of collapsing to `Equal`.
70fn type_rank(v: &Value) -> u8 {
71    match v {
72        Value::Null => 0,
73        Value::Bool(_) => 1,
74        Value::Number(_) => 2,
75        Value::String(_) => 3,
76        Value::Array(_) => 4,
77        Value::Object(_) => 5,
78    }
79}
80
81/// Exact integer view of a JSON number (`i64` or `u64`), widened to `i128` so
82/// both halves of the range compare without `f64` precision loss. `None` for
83/// non-integral (floating) numbers.
84fn number_as_i128(n: &serde_json::Number) -> Option<i128> {
85    n.as_i64()
86        .map(i128::from)
87        .or_else(|| n.as_u64().map(i128::from))
88}
89
90/// Total ordering over JSON values used for replication bookmarks.
91///
92/// - Numbers: compared exactly as `i128` when both are integral (so cursors
93///   above 2^53 don't lose precision); otherwise as `f64`, with NaN ordered
94///   last.
95/// - Same-type scalars/containers: natural ordering (strings lexicographic,
96///   bools `false < true`, arrays element-wise, objects by serialized form).
97/// - Different types: ordered by [`type_rank`] so the result is always total.
98pub(crate) fn json_compare(a: &Value, b: &Value) -> Ordering {
99    match (a, b) {
100        (Value::Number(an), Value::Number(bn)) => {
101            match (number_as_i128(an), number_as_i128(bn)) {
102                (Some(ai), Some(bi)) => ai.cmp(&bi),
103                _ => {
104                    let af = an.as_f64().unwrap_or(f64::NAN);
105                    let bf = bn.as_f64().unwrap_or(f64::NAN);
106                    af.partial_cmp(&bf).unwrap_or_else(|| {
107                        // At least one NaN — order NaN last, deterministically.
108                        match (af.is_nan(), bf.is_nan()) {
109                            (false, true) => Ordering::Less,
110                            (true, false) => Ordering::Greater,
111                            _ => Ordering::Equal,
112                        }
113                    })
114                }
115            }
116        }
117        (Value::String(x), Value::String(y)) => x.cmp(y),
118        (Value::Bool(x), Value::Bool(y)) => x.cmp(y),
119        (Value::Null, Value::Null) => Ordering::Equal,
120        (Value::Array(x), Value::Array(y)) => {
121            for (xi, yi) in x.iter().zip(y.iter()) {
122                let c = json_compare(xi, yi);
123                if c != Ordering::Equal {
124                    return c;
125                }
126            }
127            x.len().cmp(&y.len())
128        }
129        // Objects have no natural order; use the serialized form for a stable
130        // total order (objects as replication keys are pathological).
131        (Value::Object(_), Value::Object(_)) => a.to_string().cmp(&b.to_string()),
132        // Different JSON types — order by type rank so comparison is total.
133        _ => type_rank(a).cmp(&type_rank(b)),
134    }
135}
136
137/// Total-order "greater than" over JSON values, using the same comparison
138/// [`filter_incremental`] applies to replication keys (numbers numerically,
139/// strings lexicographically — so RFC3339 timestamps order correctly). Public
140/// so callers bounding a replay window (e.g. `faucet backfill --to-bookmark`)
141/// compare exactly like the incremental filter does.
142pub fn json_gt(a: &Value, b: &Value) -> bool {
143    json_compare(a, b) == Ordering::Greater
144}
145
146// ── Server-side incremental push-down (#513) ─────────────────────────────────
147
148/// The placeholder replaced by the formatted bookmark inside a
149/// [`ReplicationBind::template`].
150pub const BIND_PLACEHOLDER: &str = "${bookmark}";
151
152fn default_bind_template() -> String {
153    BIND_PLACEHOLDER.to_owned()
154}
155
156/// Where a rendered bookmark is injected into the outgoing request.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
158#[serde(rename_all = "snake_case")]
159pub enum BindTarget {
160    /// A query-string parameter (default) — e.g. `?updated_after=…`.
161    #[default]
162    Query,
163    /// A request header — e.g. `If-Modified-Since: …`.
164    Header,
165    /// A top-level field of the JSON request body (POST-search APIs).
166    Body,
167    /// A `{name}` placeholder in the request path.
168    Path,
169}
170
171/// How the bookmark value is formatted before it is substituted into the
172/// [`ReplicationBind::template`].
173///
174/// For every non-[`Raw`](BindFormat::Raw) format the bookmark is first parsed
175/// into an instant: a string is read as RFC 3339, a bare `YYYY-MM-DD` date
176/// (midnight UTC), or a naive `YYYY-MM-DDTHH:MM:SS` (assumed UTC); a JSON
177/// number is read as **epoch seconds**. It is then re-emitted in the target
178/// representation, so `epoch_ms` ← ISO string and `iso8601` ← epoch number both
179/// work.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "snake_case")]
182pub enum BindFormat {
183    /// Emit the scalar verbatim (string as-is, number as its decimal form).
184    /// The default; no timestamp parsing.
185    #[default]
186    Raw,
187    /// RFC 3339 / ISO-8601 UTC timestamp, e.g. `2024-06-01T00:00:00Z`.
188    Iso8601,
189    /// Unix epoch **seconds** (integer).
190    EpochS,
191    /// Unix epoch **milliseconds** (integer).
192    EpochMs,
193    /// Calendar date `YYYY-MM-DD` (UTC).
194    Date,
195}
196
197/// Declarative binding of the stored bookmark into the **outgoing request** —
198/// "server-side incremental push-down" (#513).
199///
200/// Today faucet tracks bookmarks and filters incrementally *client-side* (after
201/// download). A bind lets a source instead push the bookmark into the request
202/// (query param / header / body field / path) so the server returns only the
203/// new rows. The existing client-side [`filter_incremental`] stays active as a
204/// safety net for servers that don't honour the filter exactly.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
206#[serde(deny_unknown_fields)]
207pub struct ReplicationBind {
208    /// Where to place the rendered value.
209    #[serde(default)]
210    pub into: BindTarget,
211    /// The parameter / header / body-field / path-placeholder name.
212    pub name: String,
213    /// Template rendered with [`BIND_PLACEHOLDER`] (`${bookmark}`) replaced by
214    /// the formatted bookmark. Defaults to the bare `${bookmark}`; set e.g.
215    /// `"gte|${bookmark}"` (Greenhouse) or `"[${bookmark} TO *]"` (Lucene).
216    #[serde(default = "default_bind_template")]
217    pub template: String,
218    /// How to format the bookmark before substitution.
219    #[serde(default)]
220    pub format: BindFormat,
221    /// Optional JSONPath into the response body to advance the bookmark from,
222    /// instead of `max(record[replication_key])`.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub advance_from: Option<String>,
225}
226
227impl ReplicationBind {
228    /// Validate the binding at config-load time.
229    pub fn validate(&self) -> Result<(), FaucetError> {
230        if self.name.trim().is_empty() {
231            return Err(FaucetError::Config(
232                "replication bind: `name` must not be empty".to_owned(),
233            ));
234        }
235        if !self.template.contains(BIND_PLACEHOLDER) {
236            return Err(FaucetError::Config(format!(
237                "replication bind: `template` must contain the `{BIND_PLACEHOLDER}` placeholder"
238            )));
239        }
240        Ok(())
241    }
242
243    /// Render the binding for a concrete bookmark: format the value, then
244    /// substitute it into the template.
245    pub fn render(&self, bookmark: &Value) -> Result<String, FaucetError> {
246        let formatted = format_bookmark(bookmark, self.format)?;
247        Ok(self.template.replace(BIND_PLACEHOLDER, &formatted))
248    }
249}
250
251/// Parse a bookmark value into a UTC instant (see [`BindFormat`] for the rules).
252fn bookmark_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
253    match value {
254        Value::String(s) => {
255            let s = s.trim();
256            if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
257                return Ok(dt.with_timezone(&Utc));
258            }
259            if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d")
260                && let Some(ndt) = d.and_hms_opt(0, 0, 0)
261            {
262                return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
263            }
264            if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
265                return Ok(DateTime::<Utc>::from_naive_utc_and_offset(ndt, Utc));
266            }
267            Err(FaucetError::Config(format!(
268                "replication bind: cannot parse bookmark '{s}' as a timestamp \
269                 (expected RFC 3339, YYYY-MM-DD, or YYYY-MM-DDTHH:MM:SS)"
270            )))
271        }
272        Value::Number(n) => {
273            let secs = n.as_i64().or_else(|| n.as_f64().map(|f| f as i64));
274            secs.and_then(|s| DateTime::<Utc>::from_timestamp(s, 0))
275                .ok_or_else(|| {
276                    FaucetError::Config(format!(
277                        "replication bind: numeric bookmark {n} is out of range for epoch seconds"
278                    ))
279                })
280        }
281        other => Err(FaucetError::Config(format!(
282            "replication bind: bookmark must be a string or number, got {other}"
283        ))),
284    }
285}
286
287/// Parse a bookmark value into a UTC instant (public wrapper over the internal
288/// parser; see [`BindFormat`] for the accepted forms). Used by datetime window
289/// slicing (#527) to resolve the sweep's start bound from the stored bookmark.
290pub fn parse_instant(value: &Value) -> Result<DateTime<Utc>, FaucetError> {
291    bookmark_instant(value)
292}
293
294/// Format an already-resolved UTC instant per [`BindFormat`]. Unlike
295/// [`format_bookmark`] (which takes an arbitrary scalar and, for [`BindFormat::Raw`],
296/// echoes it verbatim), this always has a real instant, so `Raw` and `Iso8601`
297/// both emit an RFC 3339 UTC timestamp. Used to render window boundaries (#527).
298pub fn format_instant(dt: DateTime<Utc>, format: BindFormat) -> String {
299    match format {
300        BindFormat::Raw | BindFormat::Iso8601 => {
301            dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
302        }
303        BindFormat::Date => dt.format("%Y-%m-%d").to_string(),
304        BindFormat::EpochS => dt.timestamp().to_string(),
305        BindFormat::EpochMs => dt.timestamp_millis().to_string(),
306    }
307}
308
309/// Format a bookmark value per [`BindFormat`].
310pub fn format_bookmark(value: &Value, format: BindFormat) -> Result<String, FaucetError> {
311    match format {
312        BindFormat::Raw => match value {
313            Value::String(s) => Ok(s.clone()),
314            Value::Number(n) => Ok(n.to_string()),
315            Value::Bool(b) => Ok(b.to_string()),
316            other => Err(FaucetError::Config(format!(
317                "replication bind: cannot render {other} as a raw scalar"
318            ))),
319        },
320        BindFormat::Iso8601 => {
321            Ok(bookmark_instant(value)?.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
322        }
323        BindFormat::Date => Ok(bookmark_instant(value)?.format("%Y-%m-%d").to_string()),
324        BindFormat::EpochS => Ok(bookmark_instant(value)?.timestamp().to_string()),
325        BindFormat::EpochMs => Ok(bookmark_instant(value)?.timestamp_millis().to_string()),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use serde_json::json;
333
334    #[test]
335    fn test_filter_incremental_strings() {
336        let records = vec![
337            json!({"id": 1, "updated_at": "2024-01-01"}),
338            json!({"id": 2, "updated_at": "2024-06-01"}),
339            json!({"id": 3, "updated_at": "2024-12-01"}),
340        ];
341        let start = json!("2024-06-01");
342        let filtered = filter_incremental(records, "updated_at", &start);
343        assert_eq!(filtered.len(), 1);
344        assert_eq!(filtered[0]["id"], 3);
345    }
346
347    #[test]
348    fn test_filter_incremental_numbers() {
349        let records = vec![
350            json!({"id": 1, "seq": 100}),
351            json!({"id": 2, "seq": 200}),
352            json!({"id": 3, "seq": 300}),
353        ];
354        let start = json!(150);
355        let filtered = filter_incremental(records, "seq", &start);
356        assert_eq!(filtered.len(), 2);
357        assert_eq!(filtered[0]["id"], 2);
358        assert_eq!(filtered[1]["id"], 3);
359    }
360
361    #[test]
362    fn test_filter_incremental_missing_key_excluded() {
363        let records = vec![
364            json!({"id": 1}),
365            json!({"id": 2, "updated_at": "2024-12-01"}),
366        ];
367        let start = json!("2024-01-01");
368        let filtered = filter_incremental(records, "updated_at", &start);
369        assert_eq!(filtered.len(), 1);
370        assert_eq!(filtered[0]["id"], 2);
371    }
372
373    #[test]
374    fn test_filter_incremental_equal_excluded() {
375        let records = vec![
376            json!({"id": 1, "updated_at": "2024-06-01"}),
377            json!({"id": 2, "updated_at": "2024-06-02"}),
378        ];
379        let start = json!("2024-06-01");
380        let filtered = filter_incremental(records, "updated_at", &start);
381        assert_eq!(filtered.len(), 1);
382        assert_eq!(filtered[0]["id"], 2);
383    }
384
385    #[test]
386    fn test_max_replication_value_strings() {
387        let records = vec![
388            json!({"updated_at": "2024-01-01"}),
389            json!({"updated_at": "2024-12-01"}),
390            json!({"updated_at": "2024-06-01"}),
391        ];
392        let max = max_replication_value(&records, "updated_at").unwrap();
393        assert_eq!(max, &json!("2024-12-01"));
394    }
395
396    #[test]
397    fn test_max_replication_value_numbers() {
398        let records = vec![json!({"seq": 5}), json!({"seq": 10}), json!({"seq": 3})];
399        let max = max_replication_value(&records, "seq").unwrap();
400        assert_eq!(max, &json!(10));
401    }
402
403    #[test]
404    fn test_max_replication_value_empty() {
405        let records: Vec<Value> = vec![];
406        assert!(max_replication_value(&records, "updated_at").is_none());
407    }
408
409    #[test]
410    fn test_max_value_picks_larger_string() {
411        assert_eq!(
412            max_value(json!("2024-01-01"), json!("2024-06-01")),
413            json!("2024-06-01")
414        );
415    }
416
417    #[test]
418    fn test_max_value_picks_larger_number() {
419        assert_eq!(max_value(json!(5), json!(10)), json!(10));
420    }
421
422    #[test]
423    fn test_max_value_returns_a_on_type_mismatch() {
424        // String outranks Number in the total type-rank ordering, so the
425        // larger (a) is returned.
426        assert_eq!(max_value(json!("string"), json!(5)), json!("string"));
427    }
428
429    #[test]
430    fn filter_incremental_keeps_large_integer_beyond_f64_precision() {
431        // Regression for #78/#27: integer cursors above 2^53 lose precision
432        // when compared as f64, so a genuinely-greater value compared Equal
433        // and was silently dropped.
434        let two_pow_53 = 9_007_199_254_740_992_i64; // 2^53
435        let records = vec![
436            json!({"id": 1, "seq": two_pow_53 + 1}),
437            json!({"id": 2, "seq": two_pow_53 + 2}),
438        ];
439        let start = json!(two_pow_53);
440        let filtered = filter_incremental(records, "seq", &start);
441        assert_eq!(
442            filtered.len(),
443            2,
444            "both values are strictly greater than 2^53"
445        );
446    }
447
448    #[test]
449    fn json_compare_distinguishes_large_integers() {
450        let a = json!(9_007_199_254_740_993_i64); // 2^53 + 1
451        let b = json!(9_007_199_254_740_992_i64); // 2^53
452        assert_eq!(json_compare(&a, &b), Ordering::Greater);
453    }
454
455    #[test]
456    fn filter_incremental_keeps_records_on_type_mismatch() {
457        // Regression for #78/#27: a bookmark/key type mismatch must not be
458        // silently treated as "not greater" and the record dropped — that is
459        // data loss. Keep the record instead.
460        let records = vec![json!({"id": 1, "seq": 20_240_701})];
461        let start = json!("2024-06-01"); // string bookmark vs numeric key
462        let filtered = filter_incremental(records, "seq", &start);
463        assert_eq!(filtered.len(), 1, "type mismatch must not silently drop");
464    }
465
466    // ── ReplicationBind (#513) ──────────────────────────────────────────────
467
468    fn bind(into: BindTarget, template: &str, format: BindFormat) -> ReplicationBind {
469        ReplicationBind {
470            into,
471            name: "updated_after".to_owned(),
472            template: template.to_owned(),
473            format,
474            advance_from: None,
475        }
476    }
477
478    #[test]
479    fn bind_defaults_template_to_bare_placeholder() {
480        let b: ReplicationBind =
481            serde_json::from_value(json!({ "name": "since" })).expect("deserializes");
482        assert_eq!(b.into, BindTarget::Query);
483        assert_eq!(b.template, "${bookmark}");
484        assert_eq!(b.format, BindFormat::Raw);
485        assert!(b.advance_from.is_none());
486    }
487
488    #[test]
489    fn bind_render_raw_string_and_number() {
490        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
491        assert_eq!(b.render(&json!("2024-06-01")).unwrap(), "2024-06-01");
492        assert_eq!(b.render(&json!(150)).unwrap(), "150");
493    }
494
495    #[test]
496    fn bind_render_applies_operator_template() {
497        let b = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
498        assert_eq!(
499            b.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
500            "gte|2024-06-01T00:00:00Z"
501        );
502        // Lucene range form (Bullhorn).
503        let l = bind(BindTarget::Query, "[${bookmark} TO *]", BindFormat::Raw);
504        assert_eq!(l.render(&json!("20240601")).unwrap(), "[20240601 TO *]");
505    }
506
507    #[test]
508    fn bind_format_iso8601_from_date_and_epoch() {
509        let b = bind(BindTarget::Header, "${bookmark}", BindFormat::Iso8601);
510        assert_eq!(
511            b.render(&json!("2024-06-01")).unwrap(),
512            "2024-06-01T00:00:00Z"
513        );
514        // Epoch seconds → ISO.
515        assert_eq!(
516            b.render(&json!(1_717_200_000)).unwrap(),
517            "2024-06-01T00:00:00Z"
518        );
519    }
520
521    #[test]
522    fn bind_format_epoch_s_and_ms_from_iso() {
523        let s = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochS);
524        assert_eq!(
525            s.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
526            "1717200000"
527        );
528        let ms = bind(BindTarget::Query, "${bookmark}", BindFormat::EpochMs);
529        assert_eq!(
530            ms.render(&json!("2024-06-01T00:00:00Z")).unwrap(),
531            "1717200000000"
532        );
533    }
534
535    #[test]
536    fn bind_format_date_truncates_datetime() {
537        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Date);
538        assert_eq!(
539            b.render(&json!("2024-06-01T12:34:56Z")).unwrap(),
540            "2024-06-01"
541        );
542    }
543
544    #[test]
545    fn bind_format_naive_datetime_assumed_utc() {
546        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
547        assert_eq!(
548            b.render(&json!("2024-06-01T08:00:00")).unwrap(),
549            "2024-06-01T08:00:00Z"
550        );
551    }
552
553    #[test]
554    fn bind_format_unparseable_string_errors() {
555        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Iso8601);
556        assert!(b.render(&json!("not-a-date")).is_err());
557    }
558
559    #[test]
560    fn bind_format_raw_rejects_composite() {
561        let b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
562        assert!(b.render(&json!({"a": 1})).is_err());
563        assert!(b.render(&json!(null)).is_err());
564    }
565
566    #[test]
567    fn bind_validate_rejects_empty_name_and_missing_placeholder() {
568        let mut b = bind(BindTarget::Query, "${bookmark}", BindFormat::Raw);
569        b.name = "  ".to_owned();
570        assert!(b.validate().is_err());
571
572        let mut b2 = bind(BindTarget::Query, "no placeholder here", BindFormat::Raw);
573        b2.name = "since".to_owned();
574        assert!(b2.validate().is_err());
575
576        let ok = bind(BindTarget::Query, "gte|${bookmark}", BindFormat::Raw);
577        assert!(ok.validate().is_ok());
578    }
579
580    #[test]
581    fn bind_format_bookmark_bool_raw() {
582        assert_eq!(
583            format_bookmark(&json!(true), BindFormat::Raw).unwrap(),
584            "true"
585        );
586    }
587
588    #[test]
589    fn bind_format_non_scalar_bookmark_errors() {
590        // A composite / null bookmark cannot be parsed into an instant.
591        assert!(format_bookmark(&json!({"a": 1}), BindFormat::Iso8601).is_err());
592        assert!(format_bookmark(&json!(null), BindFormat::EpochS).is_err());
593    }
594
595    #[test]
596    fn bind_format_out_of_range_epoch_errors() {
597        // i64::MAX seconds is far outside chrono's representable range.
598        assert!(format_bookmark(&json!(i64::MAX), BindFormat::Iso8601).is_err());
599    }
600}