1use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ShardSpec {
40 pub id: String,
45
46 pub descriptor: serde_json::Value,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub size_estimate: Option<u64>,
56}
57
58impl ShardSpec {
59 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 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 pub fn with_size(mut self, size: u64) -> Self {
83 self.size_estimate = Some(size);
84 self
85 }
86
87 pub fn is_whole(&self) -> bool {
90 self.id == "0" && self.descriptor.is_null()
91 }
92}
93
94pub fn plan_pk_shards(key: &str, min: i64, max: i64, target: usize) -> Vec<ShardSpec> {
125 let target = target.max(1);
126 let width = (max as i128 - min as i128 + 1).max(1) as u128;
128 let n = (target as u128).min(width) as usize; let step = width.div_ceil(n as u128); 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; }
141 let descriptor = serde_json::json!({
142 "key": key,
143 "lo": lo as i64,
144 "hi": hi as i64,
145 "lo_unbounded": is_first,
147 "hi_unbounded": is_last,
148 "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#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct PkShardBounds {
166 pub key: String,
169 pub lo: i64,
171 pub hi: i64,
173 pub lo_unbounded: bool,
178 pub hi_unbounded: bool,
183 pub include_null: bool,
191}
192
193impl PkShardBounds {
194 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 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 "TRUE".to_string()
249 } else {
250 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
262pub 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
283pub 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
298pub 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
313pub 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
330pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
353pub struct HashShard {
354 pub shards: usize,
356 pub index: usize,
358}
359
360impl HashShard {
361 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 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
380pub 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 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 #[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 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 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; }
480 }
481
482 #[test]
483 fn plan_pk_shards_never_more_shards_than_values() {
484 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 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 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 #[test]
531 fn pk_bounds_wrap_builds_half_open_predicate() {
532 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 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 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"})); assert!(PkShardBounds::from_spec(&spec).is_none());
595 assert!(PkShardBounds::from_spec(&ShardSpec::whole()).is_none());
596 }
597
598 #[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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 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}