Skip to main content

faucet_cli/partition/
spec.rs

1//! Serde config types for the `partition:` block (#479).
2//!
3//! A partitioned row is fanned out into N independent invocations, each scoped
4//! to one chunk of a range via `${partition.*}` tokens substituted into the
5//! connector configs. This is `faucet backfill`'s window mechanism generalized:
6//! from time-only to time / integer / offset, and from a separate bounded-replay
7//! command to any run.
8//!
9//! ## Why the kinds are a tagged enum
10//!
11//! Each kind needs a different set of fields, and two of the mistakes are silent
12//! data loss rather than errors:
13//!
14//! - `bounds` is meaningful for `integer` and required there, but meaningless for
15//!   `offset`. As a tagged enum, serde makes it required exactly where it applies
16//!   instead of a runtime "you must set this when kind is …" check.
17//! - **A count is not a maximum key.** `{"total": 1234567}` equals the largest id
18//!   only when ids are dense and 1-based; chunking an *id range* from a count
19//!   silently stops early the moment ids are sparse (deletions, sharded id
20//!   allocation, non-sequential keys), and every record above it is never
21//!   fetched. Because `total` lives only on `offset` and `to` only on `integer`,
22//!   that mistake is structurally impossible rather than merely documented.
23
24use crate::chunking::Bounds;
25use crate::error::{CliError, CliResult};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29/// A partitioned range. `kind` selects the variant.
30#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
31#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
32pub enum PartitionSpec {
33    /// Split an integer range — the shape for an API or table filtered by an id
34    /// range (`?id_from=&id_to=`, `WHERE id BETWEEN`).
35    Integer {
36        /// Inclusive lower bound.
37        from: i64,
38        /// Upper bound, interpreted per `bounds`. Either a literal, or a probe
39        /// that discovers it (`{from_source: …, value_path: …}`).
40        to: IntBound,
41        /// Values per chunk. Must be > 0.
42        chunk_size: u64,
43        /// Whether `to` (and each chunk's `end`) is inclusive or exclusive.
44        ///
45        /// **Required, with no default.** Getting it wrong is silent: half-open
46        /// chunks against an inclusive-bound source fetch one record twice per
47        /// boundary; inclusive chunks against an exclusive source skip one per
48        /// boundary. Neither raises.
49        bounds: Bounds,
50        /// Render the final chunk without an upper bound, so rows appended above
51        /// `to` between planning and execution are still read.
52        ///
53        /// Unset defaults to **`true` when `to` is discovered** and `false` when
54        /// it is a literal: a probed bound is stale the moment it is read, so the
55        /// open tail is what keeps late rows from being missed. An explicit value
56        /// always wins, for a range the user knows is closed.
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        to_unbounded: Option<bool>,
59    },
60
61    /// Split a time range — the same windowing `faucet backfill` uses, including
62    /// its DST-correct calendar steps.
63    Timestamp {
64        /// RFC3339, or a bare date interpreted as midnight in `timezone`.
65        from: String,
66        /// Exclusive upper bound; time windows are always half-open.
67        to: String,
68        /// Window size: `45s`, `30m`, `6h`, `1d`, `1w`, or a bare integer =
69        /// seconds. `d`/`w` are calendar steps, so `1d` and `24h` differ across a
70        /// DST transition — deliberately.
71        chunk_size: String,
72        /// IANA timezone for date boundaries and calendar steps. Defaults to UTC.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        timezone: Option<String>,
75    },
76
77    /// Split a countable result set into offset/limit slices — the parallel form
78    /// of what a source's serial offset pagination already does.
79    Offset {
80        /// Total rows in the result set. Either a literal, or a probe that
81        /// discovers it.
82        total: CountBound,
83        /// Rows per chunk. Must be > 0.
84        chunk_size: u64,
85    },
86}
87
88/// A discoverable upper bound for an integer range.
89#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
90#[serde(untagged)]
91pub enum IntBound {
92    /// Known up front.
93    Literal(i64),
94    /// Discovered by running a probe source once — `SELECT MAX(id)`, or a
95    /// `?sort=-id&limit=1` request.
96    Discovered(BoundProbe),
97}
98
99/// A discoverable row count for an offset range.
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
101#[serde(untagged)]
102pub enum CountBound {
103    Literal(u64),
104    Discovered(BoundProbe),
105}
106
107/// Discover a bound by running a source once and reading one field out of its
108/// first record.
109///
110/// Any source works — the probe is an ordinary connector config, so a SQL
111/// `SELECT MAX(id)`, a REST "last record" request, or anything else the registry
112/// can build. That keeps discovery source-agnostic for the same reason the
113/// substitution is: no connector needs to know this feature exists.
114#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
115#[serde(deny_unknown_fields)]
116pub struct BoundProbe {
117    /// A full source config, `{type, config}`, run once before planning.
118    pub from_source: crate::config::ConnectorSpec,
119    /// JSONPath into the probe's **first** record, e.g. `$.max_id`.
120    pub value_path: String,
121}
122
123impl PartitionSpec {
124    /// Fail-fast validation, run by `faucet validate` and again before any run.
125    /// Range/size errors surface from the planners; this covers what has to be
126    /// checked before planning is even attempted.
127    pub fn validate(&self) -> CliResult<()> {
128        match self {
129            Self::Integer { chunk_size, .. } | Self::Offset { chunk_size, .. } => {
130                if *chunk_size == 0 {
131                    // Deliberately unlike the `batch_size: 0` sentinel elsewhere,
132                    // where 0 means "no batching" — here it would mean infinite
133                    // chunks, so it is an error rather than a mode.
134                    return Err(CliError::Config(
135                        "partition.chunk_size must be greater than 0 (unlike `batch_size`, \
136                         0 is not a 'no chunking' sentinel here)"
137                            .into(),
138                    ));
139                }
140            }
141            Self::Timestamp {
142                chunk_size,
143                timezone,
144                ..
145            } => {
146                crate::chunking::parse_window(chunk_size)?;
147                if let Some(tz) = timezone {
148                    tz.parse::<chrono_tz::Tz>().map_err(|_| {
149                        CliError::Config(format!(
150                            "'{tz}' is not a valid IANA timezone (e.g. UTC, America/New_York)"
151                        ))
152                    })?;
153                }
154            }
155        }
156        Ok(())
157    }
158
159    /// The token names this spec's chunks will define, for error messages that
160    /// tell the user what they *can* reference.
161    pub fn token_names(&self) -> &'static [&'static str] {
162        match self {
163            Self::Integer { .. } => &["start", "end", "index", "id"],
164            Self::Timestamp { .. } => &[
165                "start",
166                "end",
167                "start_date",
168                "end_date",
169                "start_unix",
170                "end_unix",
171                "index",
172                "id",
173            ],
174            Self::Offset { .. } => &["offset", "limit", "index", "id"],
175        }
176    }
177
178    /// Human label for logs and errors.
179    pub fn kind_str(&self) -> &'static str {
180        match self {
181            Self::Integer { .. } => "integer",
182            Self::Timestamp { .. } => "timestamp",
183            Self::Offset { .. } => "offset",
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn parse(yaml: &str) -> Result<PartitionSpec, serde_yaml::Error> {
193        serde_yaml::from_str(yaml)
194    }
195
196    #[test]
197    fn integer_requires_bounds() {
198        // The whole point of the tagged enum: omitting `bounds` on an integer
199        // range is a parse error, not a runtime surprise at the first boundary.
200        let err = parse("kind: integer\nfrom: 0\nto: 100\nchunk_size: 10\n")
201            .expect_err("bounds must be required");
202        assert!(err.to_string().contains("bounds"), "{err}");
203    }
204
205    #[test]
206    fn integer_parses_with_bounds() {
207        let s =
208            parse("kind: integer\nfrom: 0\nto: 100\nchunk_size: 10\nbounds: inclusive\n").unwrap();
209        assert!(matches!(
210            s,
211            PartitionSpec::Integer {
212                from: 0,
213                to: IntBound::Literal(100),
214                chunk_size: 10,
215                bounds: Bounds::Inclusive,
216                to_unbounded: None
217            }
218        ));
219        s.validate().unwrap();
220    }
221
222    #[test]
223    fn a_count_cannot_bound_an_id_range() {
224        // `total` belongs to `offset` only. Chunking an id range from a count is
225        // silently wrong when ids are sparse, so the shape forbids it.
226        let err = parse("kind: integer\nfrom: 0\ntotal: 1000\nchunk_size: 10\nbounds: inclusive\n")
227            .expect_err("total is not an integer-range field");
228        assert!(err.to_string().contains("total"), "{err}");
229    }
230
231    #[test]
232    fn an_id_bound_cannot_be_given_to_an_offset_range() {
233        let err = parse("kind: offset\nto: 1000\nchunk_size: 10\n")
234            .expect_err("to is not an offset field");
235        assert!(err.to_string().contains("to"), "{err}");
236    }
237
238    #[test]
239    fn offset_parses_and_needs_no_bounds() {
240        let s = parse("kind: offset\ntotal: 250\nchunk_size: 100\n").unwrap();
241        assert!(matches!(
242            s,
243            PartitionSpec::Offset {
244                total: CountBound::Literal(250),
245                chunk_size: 100
246            }
247        ));
248        s.validate().unwrap();
249    }
250
251    #[test]
252    fn timestamp_parses_and_validates_window_and_timezone() {
253        let s = parse(
254            "kind: timestamp\nfrom: 2026-01-01\nto: 2026-02-01\nchunk_size: 1d\n\
255             timezone: America/New_York\n",
256        )
257        .unwrap();
258        s.validate().unwrap();
259
260        let bad_window = parse("kind: timestamp\nfrom: a\nto: b\nchunk_size: 1y\n").unwrap();
261        assert!(bad_window.validate().is_err(), "1y is not a valid window");
262
263        let bad_tz =
264            parse("kind: timestamp\nfrom: a\nto: b\nchunk_size: 1d\ntimezone: Mars/Olympus\n")
265                .unwrap();
266        assert!(
267            bad_tz.validate().is_err(),
268            "bogus timezone must be rejected"
269        );
270    }
271
272    #[test]
273    fn zero_chunk_size_is_rejected_with_the_batch_size_distinction_spelled_out() {
274        let s =
275            parse("kind: integer\nfrom: 0\nto: 10\nchunk_size: 0\nbounds: half_open\n").unwrap();
276        let err = s.validate().unwrap_err();
277        assert!(err.to_string().contains("greater than 0"), "{err}");
278        assert!(
279            err.to_string().contains("batch_size"),
280            "the message should distinguish it from the batch_size sentinel: {err}"
281        );
282    }
283
284    #[test]
285    fn unknown_fields_are_rejected() {
286        assert!(
287            parse("kind: offset\ntotal: 10\nchunk_size: 5\nchunck_size: 5\n").is_err(),
288            "a typo must not be silently ignored"
289        );
290    }
291
292    #[test]
293    fn token_names_match_the_kind() {
294        let int =
295            parse("kind: integer\nfrom: 0\nto: 1\nchunk_size: 1\nbounds: inclusive\n").unwrap();
296        assert!(int.token_names().contains(&"start"));
297        assert!(!int.token_names().contains(&"offset"));
298
299        let off = parse("kind: offset\ntotal: 1\nchunk_size: 1\n").unwrap();
300        assert!(off.token_names().contains(&"offset"));
301        assert!(!off.token_names().contains(&"start"));
302    }
303}