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}