Skip to main content

faucet_core/
shard.rs

1//! Source sharding for clustered (Mode B) execution.
2//!
3//! A *shardable* source can split its work into independent [`ShardSpec`]s that
4//! different cluster workers process concurrently — so one logical pipeline over
5//! a single large source (a big S3 prefix, a billion-row table) scales
6//! horizontally instead of being capped at one worker's throughput.
7//!
8//! The capability is exposed as **defaulted methods on the
9//! [`Source`](crate::Source) trait**
10//! ([`enumerate_shards`](crate::Source::enumerate_shards),
11//! [`apply_shard`](crate::Source::apply_shard),
12//! [`is_shardable`](crate::Source::is_shardable)) rather than a separate trait,
13//! so it composes with the existing `Box<dyn Source>` object model with no
14//! downcast and stays fully backward compatible: a source that does not override
15//! them reports `is_shardable() == false` and enumerates to a single
16//! whole-dataset shard, i.e. it behaves exactly as today.
17//!
18//! ## Lifecycle
19//!
20//! 1. A coordinator calls [`enumerate_shards`](crate::Source::enumerate_shards)
21//!    once per run (idempotently) to discover the shard set.
22//! 2. Each shard is persisted and claimed by exactly one worker.
23//! 3. The claiming worker calls [`apply_shard`](crate::Source::apply_shard) to
24//!    narrow its source instance to that one shard before streaming.
25//! 4. Each shard carries its own state-key suffix so resume is per-shard: a
26//!    reassigned shard resumes where its dead owner left off.
27
28use serde::{Deserialize, Serialize};
29
30/// One independently-processable slice of a shardable source.
31///
32/// The [`descriptor`](ShardSpec::descriptor) is opaque to the coordinator — only
33/// the producing/consuming connector interprets it (e.g. `{"pk_range":[0,1000]}`
34/// for a key-range split, `{"prefix":"dt=2026-06-11/"}` for an object split). It
35/// must round-trip through JSON unchanged so the coordinator can persist it and
36/// hand it back to [`apply_shard`](crate::Source::apply_shard) on the worker that
37/// claims the shard.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ShardSpec {
40    /// Stable identifier, unique within a run's shard set. Also used as the
41    /// per-shard state-key suffix (`{run}::{shard.id}`), so it must be a valid
42    /// state-key segment (no `:` — see
43    /// [`validate_state_key`](crate::state::validate_state_key)).
44    pub id: String,
45
46    /// Connector-specific, opaque shard descriptor. Persisted verbatim and
47    /// passed back to [`apply_shard`](crate::Source::apply_shard).
48    pub descriptor: serde_json::Value,
49
50    /// Optional relative size estimate (rows / bytes / objects — the unit is the
51    /// connector's choice, only comparisons within one run's set matter) used
52    /// for skew-aware assignment. `None` when the source cannot cheaply
53    /// estimate; the coordinator then falls back to count-based balancing.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub size_estimate: Option<u64>,
56}
57
58impl ShardSpec {
59    /// The single whole-dataset shard a non-shardable source enumerates to.
60    ///
61    /// Its [`id`](ShardSpec::id) is `"0"` and its descriptor is JSON `null`;
62    /// [`apply_shard`](crate::Source::apply_shard)'s default ignores it, so the
63    /// source streams its entire dataset unchanged.
64    pub fn whole() -> Self {
65        Self {
66            id: "0".to_string(),
67            descriptor: serde_json::Value::Null,
68            size_estimate: None,
69        }
70    }
71
72    /// A shard with the given id and descriptor and no size estimate.
73    pub fn new(id: impl Into<String>, descriptor: serde_json::Value) -> Self {
74        Self {
75            id: id.into(),
76            descriptor,
77            size_estimate: None,
78        }
79    }
80
81    /// Attach a relative size estimate (builder style).
82    pub fn with_size(mut self, size: u64) -> Self {
83        self.size_estimate = Some(size);
84        self
85    }
86
87    /// Whether this is the whole-dataset shard (id `"0"`, null descriptor) — the
88    /// no-op shard a non-shardable source produces.
89    pub fn is_whole(&self) -> bool {
90        self.id == "0" && self.descriptor.is_null()
91    }
92}
93
94// ── PK-range sharding (shared by the SQL query sources) ─────────────────────
95
96/// Split the integer range observed as `[min, max]` into up to `target`
97/// contiguous shards, each described by
98/// `{key, lo, hi, lo_unbounded, hi_unbounded, include_null}`. Interior cut
99/// points are half-open `[lo, hi)`, but the **boundary shards are open-ended**:
100/// the first shard has no lower bound and the last shard has no upper bound, so
101/// the union of all shards tiles `(-∞, +∞)`.
102///
103/// Open-ended boundaries match unsharded semantics: `min`/`max` are captured
104/// once at enumeration time, but rows can be inserted below `min` or above `max`
105/// (or backfilled) before the workers actually stream their shards. Clamping the
106/// boundary shards to the captured `[min, max]` would silently drop those rows
107/// (audit F54/F55); leaving them open captures everything.
108///
109/// The last shard also carries `include_null: true` so that NULL-key rows —
110/// invisible to the `MIN`/`MAX` enumeration that produced `[min, max]` — are
111/// read by exactly one shard (no loss, no duplication; audit F37). Picking the
112/// *last* shard means a single-shard plan still covers NULLs.
113///
114/// Coverage scheme (proven by `predicate_coverage_*` tests):
115/// - non-NULL keys: the first shard owns `key < cut₁`, interior shards own
116///   `[cutᵢ, cutᵢ₊₁)`, and the last shard owns `key >= cutₙ` — every value
117///   (including below `min` and above `max`) falls in exactly one shard;
118/// - NULL keys: matched only by the last shard's `OR key IS NULL` clause —
119///   exactly one shard.
120///
121/// Pure function (no I/O) so it is unit-testable without a database. Shared by
122/// every PK-range-sharded SQL source (postgres, mysql, mssql, sqlite) so the
123/// subtle boundary/NULL coverage logic lives in exactly one place.
124pub fn plan_pk_shards(key: &str, min: i64, max: i64, target: usize) -> Vec<ShardSpec> {
125    let target = target.max(1);
126    // Range width as u128 to avoid i64 overflow on full-range PKs.
127    let width = (max as i128 - min as i128 + 1).max(1) as u128;
128    let n = (target as u128).min(width) as usize; // never more shards than values
129    let step = width.div_ceil(n as u128); // ceil so shards cover the whole range
130
131    let mut shards = Vec::with_capacity(n);
132    let mut lo = min as i128;
133    for i in 0..n {
134        let mut hi = lo + step as i128;
135        let is_first = i == 0;
136        let is_last = i == n - 1;
137        if is_last || hi > max as i128 {
138            hi = max as i128; // last interior cut closes at max (the last shard
139            // is unbounded above, so `hi` is unused there)
140        }
141        let descriptor = serde_json::json!({
142            "key": key,
143            "lo": lo as i64,
144            "hi": hi as i64,
145            // Boundary shards are open-ended so the union tiles (-∞, +∞).
146            "lo_unbounded": is_first,
147            "hi_unbounded": is_last,
148            // Exactly one shard (the last) owns the NULL-key rows.
149            "include_null": is_last,
150        });
151        let size = (hi - lo).max(0) as u64 + if is_last { 1 } else { 0 };
152        shards.push(ShardSpec::new(i.to_string(), descriptor).with_size(size));
153        if is_last {
154            break;
155        }
156        lo = hi;
157    }
158    shards
159}
160
161/// Parsed integer range bounds for an applied PK-range shard, produced by
162/// [`plan_pk_shards`] and applied by a SQL source's
163/// [`apply_shard`](crate::Source::apply_shard).
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct PkShardBounds {
166    /// The shard-key column name, **unquoted** — the consuming dialect quotes
167    /// it via the closure handed to [`wrap`](Self::wrap).
168    pub key: String,
169    /// Inclusive lower cut point (unused when `lo_unbounded`).
170    pub lo: i64,
171    /// Exclusive upper cut point (unused when `hi_unbounded`).
172    pub hi: i64,
173    /// When `true` this shard has **no lower bound** — it is the *first* shard,
174    /// so it owns every key below `hi` including any below the enumerated `MIN`.
175    /// Rows backfilled or inserted with smaller ids during the run are read by
176    /// this shard instead of being silently dropped outside `[MIN, MAX]` (F54).
177    pub lo_unbounded: bool,
178    /// When `true` this shard has **no upper bound** — it is the *last* shard,
179    /// so it owns every key at/above `lo` including any above the enumerated
180    /// `MAX`. Rows appended above the captured `MAX` between coordination and
181    /// shard execution are read by this shard instead of being lost (F55).
182    pub hi_unbounded: bool,
183    /// When `true` this shard *additionally* matches rows whose `key` is NULL.
184    ///
185    /// SQL aggregates (`MIN`/`MAX`) ignore NULLs, so a nullable shard key never
186    /// produces a `[lo, hi]` range covering NULL-key rows — without this flag
187    /// every sharded run would silently drop them (audit F37). Exactly one
188    /// shard (the last) carries this flag, so NULL-key rows are read by
189    /// precisely one shard: no loss, no duplication.
190    pub include_null: bool,
191}
192
193impl PkShardBounds {
194    /// Parse from a [`ShardSpec`] descriptor produced by [`plan_pk_shards`].
195    /// Returns `None` for a malformed descriptor (missing/ill-typed
196    /// `key`/`lo`/`hi`) — including the whole-dataset shard's `null`
197    /// descriptor, so callers must check [`ShardSpec::is_whole`] first.
198    pub fn from_spec(spec: &ShardSpec) -> Option<Self> {
199        let d = &spec.descriptor;
200        Some(Self {
201            key: d.get("key")?.as_str()?.to_string(),
202            lo: d.get("lo")?.as_i64()?,
203            hi: d.get("hi")?.as_i64()?,
204            lo_unbounded: d
205                .get("lo_unbounded")
206                .and_then(serde_json::Value::as_bool)
207                .unwrap_or(false),
208            hi_unbounded: d
209                .get("hi_unbounded")
210                .and_then(serde_json::Value::as_bool)
211                .unwrap_or(false),
212            include_null: d
213                .get("include_null")
214                .and_then(serde_json::Value::as_bool)
215                .unwrap_or(false),
216        })
217    }
218
219    /// Wrap `inner` so only rows whose `key` falls in this shard's range are
220    /// returned: `SELECT * FROM (<inner>) AS _faucet_shard WHERE <predicate>`.
221    ///
222    /// `quote_ident` is the consuming dialect's identifier quoting (ANSI
223    /// double quotes for postgres/sqlite, backticks for mysql, brackets for
224    /// mssql) — quoting the key makes it injection-safe. The bounds are
225    /// inlined as integer literals (safe — they are `i64`s produced by
226    /// enumeration).
227    ///
228    /// The boundary shards are **open-ended** (`lo_unbounded` / `hi_unbounded`)
229    /// so the union of all shards tiles `(-∞, +∞)`, matching unsharded
230    /// semantics — no row is dropped for sorting outside the `[MIN, MAX]`
231    /// captured at enumeration time (F54/F55). The single shard with
232    /// `include_null` also matches `key IS NULL` so NULL-key rows (invisible to
233    /// the `MIN`/`MAX` enumeration) are still read.
234    pub fn wrap(&self, inner: &str, quote_ident: impl Fn(&str) -> String) -> String {
235        let key = quote_ident(&self.key);
236        let mut parts: Vec<String> = Vec::with_capacity(2);
237        if !self.lo_unbounded {
238            parts.push(format!("{key} >= {lo}", lo = self.lo));
239        }
240        if !self.hi_unbounded {
241            parts.push(format!("{key} < {hi}", hi = self.hi));
242        }
243        let range = parts.join(" AND ");
244        let predicate = if self.include_null {
245            if range.is_empty() {
246                // A single fully-unbounded shard owns the whole dataset,
247                // NULL-key rows included.
248                "TRUE".to_string()
249            } else {
250                // Parenthesize so the OR binds correctly inside the WHERE clause.
251                format!("(({range}) OR {key} IS NULL)")
252            }
253        } else if range.is_empty() {
254            "TRUE".to_string()
255        } else {
256            range
257        };
258        format!("SELECT * FROM ({inner}) AS _faucet_shard WHERE {predicate}")
259    }
260}
261
262/// Parse + validate an applied shard for a PK-range source.
263///
264/// Returns `Ok(None)` for the whole-dataset shard (the source should clear any
265/// narrowing and stream everything) and a typed [`FaucetError::Source`](crate::FaucetError::Source) naming
266/// `connector` for a malformed descriptor. The one-line body every SQL
267/// source's [`apply_shard`](crate::Source::apply_shard) delegates to.
268pub fn parse_pk_shard(
269    shard: &ShardSpec,
270    connector: &str,
271) -> Result<Option<PkShardBounds>, crate::FaucetError> {
272    if shard.is_whole() {
273        return Ok(None);
274    }
275    PkShardBounds::from_spec(shard).map(Some).ok_or_else(|| {
276        crate::FaucetError::Source(format!(
277            "{connector}: invalid shard descriptor: {}",
278            shard.descriptor
279        ))
280    })
281}
282
283/// Build the `MIN`/`MAX` bounds probe a PK-range enumeration runs once:
284/// `SELECT CAST(MIN(<key>) AS <int_cast>) AS lo, CAST(MAX(<key>) AS <int_cast>) AS hi
285/// FROM (<inner>) AS _faucet_bounds`.
286///
287/// `quoted_key` must already be dialect-quoted; `int_cast` is the dialect's
288/// 64-bit integer cast target (`BIGINT` for postgres/mssql, `SIGNED` for
289/// mysql, `INTEGER` for sqlite).
290pub fn pk_bounds_query(inner: &str, quoted_key: &str, int_cast: &str) -> String {
291    format!(
292        "SELECT CAST(MIN({quoted_key}) AS {int_cast}) AS lo, \
293         CAST(MAX({quoted_key}) AS {int_cast}) AS hi \
294         FROM ({inner}) AS _faucet_bounds"
295    )
296}
297
298/// Turn the decoded output of a [`pk_bounds_query`] probe into a shard plan: a
299/// populated range plans PK-range shards via [`plan_pk_shards`]; an empty
300/// result set (`MIN`/`MAX` are NULL) degrades to one whole-dataset shard.
301pub fn pk_shards_from_bounds(
302    key: &str,
303    lo: Option<i64>,
304    hi: Option<i64>,
305    target: usize,
306) -> Vec<ShardSpec> {
307    match (lo, hi) {
308        (Some(lo), Some(hi)) => plan_pk_shards(key, lo, hi, target),
309        _ => vec![ShardSpec::whole()],
310    }
311}
312
313// ── Hash-of-key sharding (shared by the object-store / file sources) ────────
314
315/// Stable FNV-1a hash of an object key / file path, used to assign keys to
316/// shards.
317///
318/// Deterministic across processes and platforms (all cluster workers run the
319/// identical binary and this fixed algorithm), so every worker maps a given key
320/// to the same shard index — the partition is disjoint and complete.
321pub fn shard_hash(key: &str) -> u64 {
322    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
323    for b in key.as_bytes() {
324        h ^= *b as u64;
325        h = h.wrapping_mul(0x0000_0100_0000_01b3);
326    }
327    h
328}
329
330/// Enumerate `target` hash-modulo shards, each described by
331/// `{shards, index}`. Shard `i` owns the keys whose [`shard_hash`] is
332/// `i (mod target)`. No I/O: the partition is defined by the hash function,
333/// so enumeration is cheap and stays stable as new objects appear.
334/// `target <= 1` yields a single whole-dataset shard.
335pub fn plan_hash_shards(target: usize) -> Vec<ShardSpec> {
336    if target <= 1 {
337        return vec![ShardSpec::whole()];
338    }
339    (0..target)
340        .map(|i| {
341            ShardSpec::new(
342                i.to_string(),
343                serde_json::json!({ "shards": target, "index": i }),
344            )
345        })
346        .collect()
347}
348
349/// Parsed `{shards, index}` hash-modulo shard descriptor, produced by
350/// [`plan_hash_shards`] and applied by an object-store / file source's
351/// [`apply_shard`](crate::Source::apply_shard).
352#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub struct HashShard {
354    /// Total shard count in this run's set.
355    pub shards: usize,
356    /// This shard's index in `0..shards`.
357    pub index: usize,
358}
359
360impl HashShard {
361    /// Parse from a [`ShardSpec`] descriptor produced by [`plan_hash_shards`].
362    /// Returns `None` for a malformed descriptor (missing/ill-typed `shards`
363    /// or `index`) — including the whole-dataset shard's `null` descriptor,
364    /// so callers must check [`ShardSpec::is_whole`] first.
365    pub fn from_spec(spec: &ShardSpec) -> Option<Self> {
366        let d = &spec.descriptor;
367        Some(Self {
368            shards: d.get("shards")?.as_u64()? as usize,
369            index: d.get("index")?.as_u64()? as usize,
370        })
371    }
372
373    /// Whether `key` belongs to this shard. A degenerate set of `<= 1` shards
374    /// owns every key.
375    pub fn contains(&self, key: &str) -> bool {
376        self.shards <= 1 || (shard_hash(key) % self.shards as u64) == self.index as u64
377    }
378}
379
380/// Parse + validate an applied shard for a hash-of-key source.
381///
382/// Returns `Ok(None)` for the whole-dataset shard (the source should clear any
383/// filter and read everything) and a typed [`FaucetError::Source`](crate::FaucetError::Source) naming
384/// `connector` for a malformed descriptor. The one-line body every
385/// object-store / file source's [`apply_shard`](crate::Source::apply_shard)
386/// delegates to.
387pub fn parse_hash_shard(
388    shard: &ShardSpec,
389    connector: &str,
390) -> Result<Option<HashShard>, crate::FaucetError> {
391    if shard.is_whole() {
392        return Ok(None);
393    }
394    HashShard::from_spec(shard).map(Some).ok_or_else(|| {
395        crate::FaucetError::Source(format!(
396            "{connector}: invalid shard descriptor: {}",
397            shard.descriptor
398        ))
399    })
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use serde_json::json;
406
407    #[test]
408    fn whole_is_the_no_op_shard() {
409        let w = ShardSpec::whole();
410        assert_eq!(w.id, "0");
411        assert!(w.descriptor.is_null());
412        assert_eq!(w.size_estimate, None);
413        assert!(w.is_whole());
414    }
415
416    #[test]
417    fn new_and_with_size() {
418        let s = ShardSpec::new("3", json!({ "partition": 3 })).with_size(42);
419        assert_eq!(s.id, "3");
420        assert_eq!(s.descriptor, json!({ "partition": 3 }));
421        assert_eq!(s.size_estimate, Some(42));
422        assert!(!s.is_whole(), "a real shard is not the whole-dataset shard");
423    }
424
425    #[test]
426    fn round_trips_through_json_with_size_omitted_when_none() {
427        let s = ShardSpec::new("a", json!({ "prefix": "dt=2026-06-11/" }));
428        let text = serde_json::to_string(&s).unwrap();
429        // size_estimate is omitted from the wire form when None.
430        assert!(
431            !text.contains("size_estimate"),
432            "None size is skipped: {text}"
433        );
434        let back: ShardSpec = serde_json::from_str(&text).unwrap();
435        assert_eq!(back, s);
436    }
437
438    #[test]
439    fn round_trips_with_size_present() {
440        let s = ShardSpec::new("b", json!({ "pk_range": [0, 1000] })).with_size(1000);
441        let back: ShardSpec = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
442        assert_eq!(back, s);
443        assert_eq!(back.size_estimate, Some(1000));
444    }
445
446    // A non-whole shard with id "0" but a non-null descriptor is NOT treated as
447    // the whole-dataset shard (the descriptor disambiguates).
448    #[test]
449    fn id_zero_with_descriptor_is_not_whole() {
450        let s = ShardSpec::new("0", json!({ "partition": 0 }));
451        assert!(!s.is_whole());
452    }
453
454    // ── PK-range planning ────────────────────────────────────────────────────
455
456    /// ANSI double-quote identifier quoting, matching
457    /// `faucet_core::util::quote_ident` (not imported to keep this module
458    /// self-contained).
459    fn ansi_quote(name: &str) -> String {
460        format!("\"{}\"", name.replace('"', "\"\""))
461    }
462
463    #[test]
464    fn plan_pk_shards_covers_full_range_without_gaps_or_overlap() {
465        let shards = plan_pk_shards("id", 0, 99, 4);
466        assert_eq!(shards.len(), 4);
467        // Contiguous half-open interior cuts; boundary shards are open-ended.
468        let mut expected_lo = 0i64;
469        for (i, s) in shards.iter().enumerate() {
470            let d = &s.descriptor;
471            assert_eq!(d["key"], "id");
472            assert_eq!(d["lo"].as_i64().unwrap(), expected_lo);
473            let hi = d["hi"].as_i64().unwrap();
474            let first = i == 0;
475            let last = i == shards.len() - 1;
476            assert_eq!(d["lo_unbounded"].as_bool().unwrap(), first);
477            assert_eq!(d["hi_unbounded"].as_bool().unwrap(), last);
478            expected_lo = hi; // next shard starts where this half-open one ended
479        }
480    }
481
482    #[test]
483    fn plan_pk_shards_never_more_shards_than_values() {
484        // Range [5, 7] has 3 values; asking for 10 shards yields at most 3.
485        let shards = plan_pk_shards("pk", 5, 7, 10);
486        assert!(shards.len() <= 3, "got {} shards", shards.len());
487        assert!(
488            shards[0].descriptor["lo_unbounded"].as_bool().unwrap(),
489            "first shard is unbounded below"
490        );
491        assert!(
492            shards.last().unwrap().descriptor["hi_unbounded"]
493                .as_bool()
494                .unwrap(),
495            "last shard is unbounded above"
496        );
497    }
498
499    #[test]
500    fn plan_pk_shards_single_value_one_shard() {
501        let shards = plan_pk_shards("id", 42, 42, 8);
502        assert_eq!(shards.len(), 1);
503        // A lone shard is open-ended on both sides → the whole dataset.
504        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
505        assert!(shards[0].descriptor["hi_unbounded"].as_bool().unwrap());
506    }
507
508    #[test]
509    fn plan_pk_shards_target_zero_treated_as_one() {
510        let shards = plan_pk_shards("id", 0, 9, 0);
511        assert_eq!(shards.len(), 1);
512        assert_eq!(shards[0].descriptor["hi"].as_i64().unwrap(), 9);
513    }
514
515    #[test]
516    fn plan_pk_shards_full_i64_range_does_not_overflow() {
517        // A full-range key must not overflow the i128 width computation.
518        let shards = plan_pk_shards("id", i64::MIN, i64::MAX, 4);
519        assert_eq!(shards.len(), 4);
520        assert!(shards[0].descriptor["lo_unbounded"].as_bool().unwrap());
521        assert!(
522            shards.last().unwrap().descriptor["hi_unbounded"]
523                .as_bool()
524                .unwrap()
525        );
526    }
527
528    // ── PK-range bounds / predicate generation ──────────────────────────────
529
530    #[test]
531    fn pk_bounds_wrap_builds_half_open_predicate() {
532        // An interior shard (bounded both sides) is half-open `[lo, hi)`.
533        let spec = ShardSpec::new(
534            "1",
535            json!({"key": "id", "lo": 100, "hi": 200, "lo_unbounded": false, "hi_unbounded": false}),
536        );
537        let b = PkShardBounds::from_spec(&spec).unwrap();
538        let sql = b.wrap("SELECT * FROM t", ansi_quote);
539        assert!(sql.contains("(SELECT * FROM t) AS _faucet_shard"));
540        assert!(sql.contains(r#""id" >= 100"#), "got: {sql}");
541        assert!(
542            sql.contains(r#""id" < 200"#),
543            "half-open upper bound: {sql}"
544        );
545    }
546
547    #[test]
548    fn pk_bounds_wrap_first_shard_has_no_lower_bound() {
549        // F54: the first shard omits the `>= lo` floor so keys below the
550        // enumerated MIN are still read.
551        let spec = ShardSpec::new(
552            "0",
553            json!({"key": "id", "lo": 0, "hi": 100, "lo_unbounded": true, "hi_unbounded": false}),
554        );
555        let b = PkShardBounds::from_spec(&spec).unwrap();
556        let sql = b.wrap("SELECT * FROM t", ansi_quote);
557        assert!(sql.contains(r#""id" < 100"#), "upper bound present: {sql}");
558        assert!(!sql.contains(">="), "first shard has no lower floor: {sql}");
559    }
560
561    #[test]
562    fn pk_bounds_wrap_last_shard_has_no_upper_bound() {
563        // F55: the last shard omits the upper bound so keys above the
564        // enumerated MAX are still read.
565        let spec = ShardSpec::new(
566            "2",
567            json!({"key": "id", "lo": 200, "hi": 300, "lo_unbounded": false, "hi_unbounded": true}),
568        );
569        let b = PkShardBounds::from_spec(&spec).unwrap();
570        let sql = b.wrap("SELECT * FROM t", ansi_quote);
571        assert!(sql.contains(r#""id" >= 200"#), "lower bound present: {sql}");
572        assert!(
573            !sql.contains(" < ") && !sql.contains("<="),
574            "last shard has no upper bound: {sql}"
575        );
576    }
577
578    #[test]
579    fn pk_bounds_wrap_uses_the_supplied_dialect_quoting() {
580        let spec = ShardSpec::new(
581            "0",
582            json!({"key": "id", "lo": 0, "hi": 1, "lo_unbounded": false, "hi_unbounded": false}),
583        );
584        let b = PkShardBounds::from_spec(&spec).unwrap();
585        let backtick = b.wrap("SELECT 1", |k| format!("`{k}`"));
586        assert!(backtick.contains("`id` >= 0"), "got: {backtick}");
587        let bracket = b.wrap("SELECT 1", |k| format!("[{k}]"));
588        assert!(bracket.contains("[id] >= 0"), "got: {bracket}");
589    }
590
591    #[test]
592    fn pk_bounds_from_spec_rejects_malformed_descriptor() {
593        let spec = ShardSpec::new("0", json!({"key": "id"})); // no lo/hi
594        assert!(PkShardBounds::from_spec(&spec).is_none());
595        assert!(PkShardBounds::from_spec(&ShardSpec::whole()).is_none());
596    }
597
598    // ── F37: NULL-key shard coverage ────────────────────────────────────────
599
600    #[test]
601    fn exactly_one_shard_includes_null() {
602        let shards = plan_pk_shards("id", 0, 99, 5);
603        let null_owners: Vec<usize> = shards
604            .iter()
605            .enumerate()
606            .filter(|(_, s)| s.descriptor["include_null"].as_bool().unwrap_or(false))
607            .map(|(i, _)| i)
608            .collect();
609        assert_eq!(
610            null_owners,
611            vec![shards.len() - 1],
612            "exactly the last shard owns NULL keys"
613        );
614    }
615
616    #[test]
617    fn single_shard_plan_still_owns_null() {
618        // A single value yields one shard; it must still cover NULL keys.
619        let shards = plan_pk_shards("id", 7, 7, 4);
620        assert_eq!(shards.len(), 1);
621        assert!(shards[0].descriptor["include_null"].as_bool().unwrap());
622    }
623
624    #[test]
625    fn last_shard_wrap_emits_is_null_clause() {
626        let shards = plan_pk_shards("id", 0, 99, 3);
627        let last = PkShardBounds::from_spec(shards.last().unwrap()).unwrap();
628        let sql = last.wrap("SELECT * FROM t", ansi_quote);
629        assert!(
630            sql.contains(r#""id" IS NULL"#),
631            "last shard must match NULL keys: {sql}"
632        );
633        assert!(sql.contains(" OR "), "NULL clause OR'd with range: {sql}");
634    }
635
636    #[test]
637    fn non_last_shard_wrap_omits_is_null_clause() {
638        let shards = plan_pk_shards("id", 0, 99, 3);
639        // First shard is not the last → no NULL clause.
640        let first = PkShardBounds::from_spec(&shards[0]).unwrap();
641        let sql = first.wrap("SELECT * FROM t", ansi_quote);
642        assert!(
643            !sql.contains("IS NULL"),
644            "non-last shard must not match NULL keys: {sql}"
645        );
646    }
647
648    /// Property check on the generated predicates: OR-ing every shard's WHERE
649    /// predicate must cover (a) every non-NULL key — including values *outside*
650    /// the enumerated `[min, max]` (F54/F55) — exactly once and (b) NULL keys
651    /// exactly once.
652    #[test]
653    fn predicate_coverage_complete_and_non_overlapping() {
654        let (min, max, target) = (0i64, 19i64, 4usize);
655        let bounds: Vec<PkShardBounds> = plan_pk_shards("k", min, max, target)
656            .iter()
657            .map(|s| PkShardBounds::from_spec(s).unwrap())
658            .collect();
659
660        // The boundary shards model SQL membership: open below for the first
661        // shard, open above for the last.
662        let matches_key = |b: &PkShardBounds, key: i64| -> bool {
663            let lower = b.lo_unbounded || key >= b.lo;
664            let upper = b.hi_unbounded || key < b.hi;
665            lower && upper
666        };
667
668        // (a) Every non-NULL key — well below min, in range, and well above max
669        // — matches exactly one shard. Keys outside [min, max] model rows
670        // inserted/backfilled during the coordinate→execute window.
671        for key in (min - 50)..=(max + 50) {
672            let matches = bounds.iter().filter(|b| matches_key(b, key)).count();
673            assert_eq!(matches, 1, "key {key} matched {matches} shards (want 1)");
674        }
675
676        // (b) NULL keys match exactly one shard (the one with include_null).
677        let null_matches = bounds.iter().filter(|b| b.include_null).count();
678        assert_eq!(null_matches, 1, "NULL keys must match exactly one shard");
679    }
680
681    #[test]
682    fn single_shard_wrap_selects_whole_dataset_including_null() {
683        // A lone open-ended shard must select every row, NULL keys included.
684        let shards = plan_pk_shards("id", 7, 7, 1);
685        assert_eq!(shards.len(), 1);
686        let b = PkShardBounds::from_spec(&shards[0]).unwrap();
687        let sql = b.wrap("SELECT * FROM t", ansi_quote);
688        assert!(sql.contains("WHERE TRUE"), "whole-dataset predicate: {sql}");
689        assert!(!sql.contains(">="), "no bounds on a lone shard: {sql}");
690    }
691
692    // ── Hash-of-key sharding ────────────────────────────────────────────────
693
694    #[test]
695    fn shard_hash_is_deterministic() {
696        assert_eq!(
697            shard_hash("data/part-001.jsonl"),
698            shard_hash("data/part-001.jsonl")
699        );
700        assert_ne!(shard_hash("a"), shard_hash("b"));
701    }
702
703    #[test]
704    fn plan_hash_shards_returns_target_disjoint_shards() {
705        let shards = plan_hash_shards(3);
706        assert_eq!(shards.len(), 3);
707        for (i, s) in shards.iter().enumerate() {
708            assert_eq!(s.descriptor["shards"], 3);
709            assert_eq!(s.descriptor["index"], i);
710            assert_eq!(s.id, i.to_string());
711        }
712    }
713
714    #[test]
715    fn plan_hash_shards_target_one_is_whole() {
716        let shards = plan_hash_shards(1);
717        assert_eq!(shards.len(), 1);
718        assert!(shards[0].is_whole());
719        let shards = plan_hash_shards(0);
720        assert_eq!(shards.len(), 1);
721        assert!(shards[0].is_whole());
722    }
723
724    // The union of every shard's owned key set equals the full set, with no
725    // key in two shards — the core no-dup / no-loss guarantee.
726    #[test]
727    fn hash_shards_partition_keys_disjointly_and_completely() {
728        let keys: Vec<String> = (0..200).map(|i| format!("data/obj-{i}.jsonl")).collect();
729        let n = 4;
730        let members: Vec<HashShard> = plan_hash_shards(n)
731            .iter()
732            .map(|s| HashShard::from_spec(s).unwrap())
733            .collect();
734        for key in &keys {
735            let owners = members.iter().filter(|m| m.contains(key)).count();
736            assert_eq!(owners, 1, "key {key} owned by {owners} shards (want 1)");
737        }
738    }
739
740    #[test]
741    fn hash_shard_degenerate_single_shard_owns_everything() {
742        let m = HashShard {
743            shards: 1,
744            index: 0,
745        };
746        assert!(m.contains("anything"));
747    }
748
749    #[test]
750    fn hash_shard_from_spec_rejects_malformed_descriptor() {
751        assert!(HashShard::from_spec(&ShardSpec::new("0", json!({ "index": 0 }))).is_none());
752        assert!(HashShard::from_spec(&ShardSpec::new("0", json!({ "shards": 4 }))).is_none());
753        assert!(HashShard::from_spec(&ShardSpec::whole()).is_none());
754    }
755
756    #[test]
757    fn hash_shard_round_trips_plan_descriptors() {
758        for spec in plan_hash_shards(5) {
759            let m = HashShard::from_spec(&spec).unwrap();
760            assert_eq!(m.shards, 5);
761            assert_eq!(m.index, spec.id.parse::<usize>().unwrap());
762        }
763    }
764
765    // ── Connector-glue helpers (parse / bounds probe / plan-from-bounds) ─────
766
767    #[test]
768    fn parse_pk_shard_whole_clears_and_real_parses() {
769        assert!(parse_pk_shard(&ShardSpec::whole(), "t").unwrap().is_none());
770        let spec = &plan_pk_shards("id", 0, 99, 3)[1];
771        let bounds = parse_pk_shard(spec, "t").unwrap().unwrap();
772        assert_eq!(bounds.key, "id");
773    }
774
775    #[test]
776    fn parse_pk_shard_malformed_names_connector() {
777        let bad = ShardSpec::new("0", json!({ "key": "id" }));
778        let err = parse_pk_shard(&bad, "mysql").unwrap_err();
779        assert!(err.to_string().contains("mysql"), "got: {err}");
780    }
781
782    #[test]
783    fn parse_hash_shard_whole_clears_and_real_parses() {
784        assert!(
785            parse_hash_shard(&ShardSpec::whole(), "t")
786                .unwrap()
787                .is_none()
788        );
789        let spec = &plan_hash_shards(3)[2];
790        let m = parse_hash_shard(spec, "t").unwrap().unwrap();
791        assert_eq!((m.shards, m.index), (3, 2));
792    }
793
794    #[test]
795    fn parse_hash_shard_malformed_names_connector() {
796        let bad = ShardSpec::new("0", json!({ "index": 0 }));
797        let err = parse_hash_shard(&bad, "gcs").unwrap_err();
798        assert!(err.to_string().contains("gcs"), "got: {err}");
799    }
800
801    #[test]
802    fn pk_bounds_query_shapes_the_probe() {
803        let sql = pk_bounds_query("SELECT * FROM t", "\"id\"", "BIGINT");
804        assert_eq!(
805            sql,
806            "SELECT CAST(MIN(\"id\") AS BIGINT) AS lo, CAST(MAX(\"id\") AS BIGINT) AS hi \
807             FROM (SELECT * FROM t) AS _faucet_bounds"
808        );
809    }
810
811    #[test]
812    fn pk_shards_from_bounds_plans_or_degrades() {
813        let shards = pk_shards_from_bounds("id", Some(0), Some(99), 4);
814        assert_eq!(shards.len(), 4);
815        // NULL MIN/MAX (empty result set) → one whole shard.
816        for (lo, hi) in [(None, None), (Some(1), None), (None, Some(1))] {
817            let shards = pk_shards_from_bounds("id", lo, hi, 4);
818            assert_eq!(shards.len(), 1);
819            assert!(shards[0].is_whole());
820        }
821    }
822}