Skip to main content

mz_storage_types/
stats.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Types and traits that connect up our mz-repr types with the stats that persist maintains.
11
12use mz_expr::{
13    ColumnSpecs, Interpretable, Interpreter, MapFilterProject, MfpPlan, OptimizableExpr,
14    ResultSpec, UnmaterializableFunc,
15};
16use mz_persist_types::stats::{
17    BytesStats, ColumnStatKinds, JsonStats, PartStats, PartStatsMetrics,
18};
19use mz_repr::{
20    ColumnIndex, Datum, RelationDesc, ReprRelationType, RowArena, SqlColumnType, SqlScalarType,
21};
22
23/// Bundles together a relation desc with the stats for a specific part, and translates between
24/// Persist's stats representation and the `ResultSpec`s that are used for eg. filter pushdown.
25#[derive(Debug)]
26pub struct RelationPartStats<'a> {
27    pub(crate) name: &'a str,
28    pub(crate) metrics: &'a PartStatsMetrics,
29    pub(crate) desc: &'a RelationDesc,
30    pub(crate) stats: &'a PartStats,
31}
32
33impl<'a> RelationPartStats<'a> {
34    pub fn new(
35        name: &'a str,
36        metrics: &'a PartStatsMetrics,
37        desc: &'a RelationDesc,
38        stats: &'a PartStats,
39    ) -> Self {
40        Self {
41            name,
42            metrics,
43            desc,
44            stats,
45        }
46    }
47}
48
49impl RelationPartStats<'_> {
50    pub fn may_match_mfp<'a, X: Interpretable + OptimizableExpr>(
51        &'a self,
52        time_range: ResultSpec<'a>,
53        mfp: &MapFilterProject<X>,
54    ) -> bool {
55        self.may_match(time_range, |ranges| ranges.mfp_filter(mfp))
56    }
57
58    /// Like [`Self::may_match_mfp`], for operators already planned, with the
59    /// temporal bounds broken out.
60    pub fn may_match_mfp_plan<'a, X: Interpretable + OptimizableExpr>(
61        &'a self,
62        time_range: ResultSpec<'a>,
63        plan: &MfpPlan<X>,
64    ) -> bool {
65        self.may_match(time_range, |ranges| ranges.mfp_plan_filter(plan))
66    }
67
68    fn may_match<'a>(
69        &'a self,
70        time_range: ResultSpec<'a>,
71        filter: impl for<'b> FnOnce(&ColumnSpecs<'b>) -> mz_expr::ColumnSpec<'b>,
72    ) -> bool {
73        let arena = RowArena::new();
74        let relation = ReprRelationType::from(self.desc.typ());
75        let mut ranges = ColumnSpecs::new(&relation, &arena);
76        ranges.push_unmaterializable(UnmaterializableFunc::MzNow, time_range);
77
78        // If the error collection is nonempty, we always keep the part.
79        // Missing err stats mean errors cannot be ruled out, so they count as
80        // "may error" too, matching the storage read path's `filter_result`.
81        if self.err_count().is_none_or(|count| count > 0) {
82            return true;
83        }
84
85        for (pos, (idx, _name, _typ)) in self.desc.iter_all().enumerate() {
86            let result_spec = self.col_stats(idx, &arena);
87            ranges.push_column(pos, result_spec);
88        }
89        let result = filter(&ranges).range;
90        result.may_contain(Datum::True) || result.may_fail()
91    }
92
93    fn json_spec<'a>(len: usize, stats: &'a JsonStats, arena: &'a RowArena) -> ResultSpec<'a> {
94        match stats {
95            JsonStats::JsonNulls => ResultSpec::value(Datum::JsonNull),
96            JsonStats::Bools(bools) => {
97                ResultSpec::value_between(bools.lower.into(), bools.upper.into())
98            }
99            JsonStats::Strings(strings) => ResultSpec::value_between(
100                Datum::String(strings.lower.as_str()),
101                Datum::String(strings.upper.as_str()),
102            ),
103            JsonStats::Numerics(numerics) => {
104                match mz_repr::stats::decode_numeric(numerics, arena) {
105                    Ok((lower, upper)) => ResultSpec::value_between(lower, upper),
106                    Err(err) => {
107                        tracing::error!(%err, "failed to decode Json Numeric stats!");
108                        ResultSpec::anything()
109                    }
110                }
111            }
112            JsonStats::Maps(maps) => {
113                ResultSpec::map_spec(
114                    maps.into_iter()
115                        .map(|(k, v)| {
116                            let mut v_spec = Self::json_spec(v.len, &v.stats, arena);
117                            if v.len != len {
118                                // This field is not always present, so assume
119                                // that accessing it might be null.
120                                v_spec = v_spec.union(ResultSpec::null());
121                            }
122                            let key = arena.make_datum(|r| r.push(Datum::String(k.as_str())));
123                            (key, v_spec)
124                        })
125                        .collect(),
126                )
127            }
128            JsonStats::None => ResultSpec::nothing(),
129            JsonStats::Lists | JsonStats::Mixed => ResultSpec::anything(),
130        }
131    }
132
133    pub fn col_stats<'a>(&'a self, idx: &ColumnIndex, arena: &'a RowArena) -> ResultSpec<'a> {
134        let value_range = match self.col_values(idx, arena) {
135            Some(spec) => spec,
136            None => ResultSpec::anything(),
137        };
138        let json_range = self
139            .col_json(idx, arena)
140            .unwrap_or_else(ResultSpec::anything);
141
142        // If this is not a JSON column or we don't have JSON stats, json_range is
143        // [ResultSpec::anything] and this is a noop.
144        value_range.intersect(json_range)
145    }
146
147    fn col_json<'a>(&'a self, idx: &ColumnIndex, arena: &'a RowArena) -> Option<ResultSpec<'a>> {
148        let name = self.desc.get_name_idx(idx);
149        let typ = &self.desc.get_type(idx);
150
151        let ok_stats = self.stats.key.col("ok")?;
152        // These stats come straight off durable state, so a corrupt or
153        // version-skewed encoding can carry any shape here. Report the column
154        // range as unknown rather than panicking the process reading it.
155        let ok_stats = match ok_stats.try_as_optional_struct() {
156            Ok(ok_stats) => ok_stats,
157            Err(err) => {
158                self.metrics.mismatched_count.inc();
159                tracing::error!(
160                    "expected nullable struct stats for the 'ok' column of {}: {err}",
161                    self.name
162                );
163                return None;
164            }
165        };
166        let col_stats = ok_stats.some.cols.get(name.as_str())?;
167
168        if let SqlColumnType {
169            scalar_type: SqlScalarType::Jsonb,
170            nullable,
171        } = typ
172        {
173            let value_range = match &col_stats.values {
174                ColumnStatKinds::Bytes(BytesStats::Json(json_stats)) => {
175                    Self::json_spec(ok_stats.some.len, json_stats, arena)
176                }
177                ColumnStatKinds::Bytes(
178                    BytesStats::Primitive(_) | BytesStats::Atomic(_) | BytesStats::FixedSize(_),
179                ) => ResultSpec::anything(),
180                other => {
181                    self.metrics.mismatched_count.inc();
182                    tracing::error!(
183                        "expected BytesStats for JSON column {}, found {other:?}",
184                        self.name
185                    );
186                    return None;
187                }
188            };
189            let null_range = match (nullable, col_stats.nulls) {
190                (false, None) => ResultSpec::nothing(),
191                (true, Some(nulls)) if nulls.count == 0 => ResultSpec::nothing(),
192                (true, Some(_)) => ResultSpec::null(),
193                (col_null, stats_null) => {
194                    self.metrics.mismatched_count.inc();
195                    tracing::error!(
196                        "JSON column nullability mismatch, col {} null: {col_null}, stats: {stats_null:?}",
197                        self.name
198                    );
199                    return None;
200                }
201            };
202
203            Some(null_range.union(value_range))
204        } else {
205            None
206        }
207    }
208
209    pub fn len(&self) -> Option<usize> {
210        Some(self.stats.key.len)
211    }
212
213    pub fn ok_count(&self) -> Option<usize> {
214        // The number of OKs is the number of rows whose error is None.
215        // Malformed or wrong-shaped err stats (corrupt or version-skewed
216        // durable state) count as unknown, which callers treat as
217        // "may contain errors", rather than panicking the replica.
218        let stats = self.stats.key.col("err")?.try_as_optional_bytes().ok()?;
219        Some(stats.none)
220    }
221
222    pub fn err_count(&self) -> Option<usize> {
223        // Counter-intuitive: We can easily calculate the number of errors that
224        // were None from the column stats, but not how many were Some. So, what
225        // we do is count the number of Nones, which is the number of Oks, and
226        // then subtract that from the total.
227        let num_results = self.stats.key.len;
228        let num_oks = self.ok_count();
229        // An ok count exceeding the part length is corrupt stats; report the
230        // err count as unknown (callers keep the part) instead of underflowing.
231        num_oks.and_then(|num_oks| num_results.checked_sub(num_oks))
232    }
233
234    fn col_values<'a>(&'a self, idx: &ColumnIndex, arena: &'a RowArena) -> Option<ResultSpec<'a>> {
235        let name = self.desc.get_name_idx(idx);
236        let typ = self.desc.get_type(idx);
237
238        let ok_stats = self.stats.key.cols.get("ok")?;
239        // See the note in `col_json`: durable stats can be any shape, so a
240        // wrong-shaped 'ok' column makes the range unknown, not a panic.
241        let ColumnStatKinds::Struct(ok_stats) = &ok_stats.values else {
242            self.metrics.mismatched_count.inc();
243            tracing::error!(
244                "expected struct stats for the 'ok' column of {}, found {:?}",
245                self.name,
246                ok_stats.values
247            );
248            return None;
249        };
250        let col_stats = ok_stats.cols.get(name.as_str())?;
251
252        let min_max = mz_repr::stats::col_values(&typ.scalar_type, &col_stats.values, arena);
253        let null_count = col_stats.nulls.as_ref().map_or(0, |nulls| nulls.count);
254        let total_count = self.len();
255
256        let values = match (total_count, min_max) {
257            (Some(total_count), _) if total_count == null_count => ResultSpec::nothing(),
258            (_, Some((min, max))) => ResultSpec::value_between(min, max),
259            _ => ResultSpec::value_all(),
260        };
261        let nulls = if null_count > 0 {
262            ResultSpec::null()
263        } else {
264            ResultSpec::nothing()
265        };
266
267        Some(values.union(nulls))
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use arrow::array::AsArray;
274    use mz_ore::metrics::MetricsRegistry;
275    use mz_persist_types::codec_impls::UnitSchema;
276    use mz_persist_types::columnar::{ColumnDecoder, Schema};
277    use mz_persist_types::part::PartBuilder;
278    use mz_persist_types::stats::{PartStats, ProtoStructStats, TrimStats, trim_to_budget};
279    use mz_proto::RustType;
280    use mz_repr::{Datum, RelationDesc, Row, RowArena, SqlColumnType, SqlScalarType};
281    use mz_repr::{SqlRelationType, arb_datum_for_column};
282    use proptest::prelude::*;
283    use proptest::strategy::ValueTree;
284
285    use super::*;
286    use crate::sources::SourceData;
287
288    fn validate_stats(column_type: &SqlColumnType, datums: &[Datum<'_>]) -> Result<(), String> {
289        let schema = RelationDesc::builder()
290            .with_column("col", column_type.clone())
291            .finish();
292
293        let mut builder = PartBuilder::new(&schema, &UnitSchema);
294        let mut row = SourceData(Ok(Row::default()));
295        for datum in datums {
296            row.as_mut().unwrap().packer().push(datum);
297            builder.push(&row, &(), 1u64, 1i64);
298        }
299        let part = builder.finish();
300
301        let key_col = part.key.as_struct();
302        let decoder = <RelationDesc as Schema<SourceData>>::decoder(&schema, key_col.clone())
303            .expect("success");
304        let key_stats = decoder.stats();
305
306        // Trimming may widen bounds or drop them entirely, but must never
307        // narrow them, so the containment check below has to hold after every
308        // trimming pass: the lossy-but-column-preserving `trim`, and
309        // `trim_to_budget` at budgets all the way down to one that drops
310        // every column. The force-keep column matches the production default
311        // of never trimming the err column's stats.
312        let proto: ProtoStructStats = RustType::into_proto(&key_stats);
313        let mut variants = vec![("collected".to_string(), key_stats)];
314        {
315            let mut trimmed = proto.clone();
316            trimmed.trim();
317            variants.push((
318                "trimmed".to_string(),
319                RustType::from_proto(trimmed).expect("valid proto"),
320            ));
321        }
322        let full = prost::Message::encoded_len(&proto);
323        for budget in [full / 2, full / 4, 16, 0] {
324            let mut trimmed = proto.clone();
325            trim_to_budget(&mut trimmed, budget, |col| col == "err");
326            variants.push((
327                format!("trim_to_budget({budget})"),
328                RustType::from_proto(trimmed).expect("valid proto"),
329            ));
330        }
331
332        let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
333        for (label, key_stats) in variants {
334            let stats = RelationPartStats {
335                name: "test",
336                metrics: &metrics,
337                stats: &PartStats { key: key_stats },
338                desc: &schema,
339            };
340            let arena = RowArena::default();
341
342            // Validate that the stats would include all of the provided datums.
343            for datum in datums {
344                let spec = stats.col_stats(&ColumnIndex::from_raw(0), &arena);
345                if !spec.may_contain(*datum) {
346                    return Err(format!(
347                        "{label} stats-derived spec claims {datum:?} is absent from a part that \
348                         contains it (type: {:?}, part: {datums:?}, spec: {spec:?})",
349                        column_type.scalar_type,
350                    ));
351                }
352            }
353        }
354
355        Ok(())
356    }
357
358    fn scalar_type_stats_roundtrip(scalar_type: SqlScalarType) {
359        // Non-nullable version of the column.
360        let column_type = scalar_type.clone().nullable(false);
361        for datum in scalar_type.interesting_datums() {
362            assert_eq!(validate_stats(&column_type, &[datum]), Ok(()));
363        }
364
365        // Nullable version of the column.
366        let column_type = scalar_type.clone().nullable(true);
367        for datum in scalar_type.interesting_datums() {
368            assert_eq!(validate_stats(&column_type, &[datum]), Ok(()));
369        }
370        assert_eq!(validate_stats(&column_type, &[Datum::Null]), Ok(()));
371    }
372
373    #[mz_ore::test]
374    #[cfg_attr(miri, ignore)] // too slow
375    fn all_scalar_types_stats_roundtrip() {
376        proptest!(|(scalar_type in any::<SqlScalarType>())| {
377            // The proptest! macro interferes with rustfmt.
378            scalar_type_stats_roundtrip(scalar_type)
379        });
380    }
381
382    /// Deterministic sweep over multi-datum parts: every pair of interesting
383    /// datums and the full set, packed into a single part per type.
384    ///
385    /// Part bounds are computed over the whole part, so a value whose ordering
386    /// the stats collection disagrees on (e.g. -NaN, which arrow's total order
387    /// puts below -Infinity but `OrderedFloat` ranks above every finite value)
388    /// can invalidate the bounds for *other* values in the part. Single-datum
389    /// parts, as covered by `all_scalar_types_stats_roundtrip`, can never
390    /// catch that class of bug.
391    #[mz_ore::test]
392    #[cfg_attr(miri, ignore)] // too slow
393    fn interesting_datum_combinations_stats_roundtrip() {
394        for scalar_type in SqlScalarType::enumerate() {
395            let datums: Vec<_> = scalar_type.interesting_datums().collect();
396            if datums.is_empty() {
397                continue;
398            }
399            for nullable in [false, true] {
400                let column_type = scalar_type.clone().nullable(nullable);
401                for (i, a) in datums.iter().enumerate() {
402                    for b in &datums[i + 1..] {
403                        assert_eq!(validate_stats(&column_type, &[*a, *b]), Ok(()));
404                    }
405                    if nullable {
406                        assert_eq!(validate_stats(&column_type, &[*a, Datum::Null]), Ok(()));
407                    }
408                }
409                let mut all = datums.clone();
410                if nullable {
411                    all.push(Datum::Null);
412                }
413                assert_eq!(validate_stats(&column_type, &all[..]), Ok(()));
414            }
415        }
416    }
417
418    #[mz_ore::test]
419    #[cfg_attr(miri, ignore)] // too slow
420    fn all_datums_produce_valid_stats() {
421        // A strategy that will return a Vec of Datums for an arbitrary SqlColumnType.
422        let datums = any::<SqlColumnType>().prop_flat_map(|ty| {
423            prop::collection::vec(arb_datum_for_column(ty.clone()), 0..128)
424                .prop_map(move |datums| (ty.clone(), datums))
425        });
426
427        proptest!(
428            ProptestConfig::with_cases(80),
429            |((ty, datums) in datums)| {
430                // The proptest! macro interferes with rustfmt.
431                let datums: Vec<_> = datums.iter().map(Datum::from).collect();
432                prop_assert_eq!(validate_stats(&ty, &datums[..]), Ok(()));
433            }
434        )
435    }
436
437    /// The err column's stats are force-kept from trimming by default, but
438    /// that list is configurable, so they can be absent. When they are,
439    /// `may_match_mfp` must treat the part as possibly containing errors,
440    /// exactly like `filter_result` does: error rows must surface regardless
441    /// of any filter, so a part that may hold one can never be skipped.
442    #[mz_ore::test]
443    #[cfg_attr(miri, ignore)] // too slow
444    fn may_match_mfp_missing_err_stats_keeps_part() {
445        use mz_expr::{BinaryFunc, EvalError, MirScalarExpr, func};
446        use mz_repr::ReprScalarType;
447
448        use crate::errors::DataflowError;
449
450        let schema = RelationDesc::builder()
451            .with_column("col", SqlScalarType::Int32.nullable(false))
452            .finish();
453        let mut builder = PartBuilder::new(&schema, &UnitSchema);
454        builder.push(
455            &SourceData(Ok(Row::pack_slice(&[Datum::Int32(1)]))),
456            &(),
457            1u64,
458            1i64,
459        );
460        builder.push(
461            &SourceData(Err(DataflowError::from(EvalError::DivisionByZero))),
462            &(),
463            1u64,
464            1i64,
465        );
466        let part = builder.finish();
467        let key_col = part.key.as_struct();
468        let decoder = <RelationDesc as Schema<SourceData>>::decoder(&schema, key_col.clone())
469            .expect("success");
470        let mut key_stats = decoder.stats();
471        // Simulate the err column's stats having been trimmed away.
472        key_stats.cols.remove("err").expect("err stats present");
473
474        let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
475        let stats = RelationPartStats {
476            name: "test",
477            metrics: &metrics,
478            stats: &PartStats { key: key_stats },
479            desc: &schema,
480        };
481        // No Ok row matches this filter: only the error row makes the part
482        // relevant, and with the err stats missing it cannot be ruled out.
483        let mfp = MapFilterProject::new(1).filter(std::iter::once(MirScalarExpr::CallBinary {
484            func: BinaryFunc::Eq(func::Eq),
485            expr1: Box::new(MirScalarExpr::column(0)),
486            expr2: Box::new(MirScalarExpr::literal_ok(
487                Datum::Int32(999),
488                ReprScalarType::Int32,
489            )),
490        }));
491        assert!(stats.may_match_mfp(ResultSpec::anything(), &mfp));
492    }
493
494    /// Wrong-shaped err-column stats (corrupt or version-skewed durable
495    /// state) must read as "err count unknown", which fails open to keeping
496    /// the part, not panic the replica.
497    #[mz_ore::test]
498    #[cfg_attr(miri, ignore)] // too slow
499    fn malformed_err_stats_fail_open() {
500        use mz_persist_types::stats::{ColumnNullStats, ColumnarStats, PrimitiveStats};
501
502        let schema = RelationDesc::builder()
503            .with_column("col", SqlScalarType::Int32.nullable(false))
504            .finish();
505        let mut builder = PartBuilder::new(&schema, &UnitSchema);
506        builder.push(
507            &SourceData(Ok(Row::pack_slice(&[Datum::Int32(1)]))),
508            &(),
509            1u64,
510            1i64,
511        );
512        let part = builder.finish();
513        let key_col = part.key.as_struct();
514        let decoder = <RelationDesc as Schema<SourceData>>::decoder(&schema, key_col.clone())
515            .expect("success");
516        let mut key_stats = decoder.stats();
517        // Overwrite the err column's stats with a wrong-shaped entry.
518        key_stats.cols.insert(
519            "err".to_string(),
520            ColumnarStats {
521                nulls: Some(ColumnNullStats { count: 0 }),
522                values: PrimitiveStats {
523                    lower: 0i32,
524                    upper: 0i32,
525                }
526                .into(),
527            },
528        );
529
530        let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
531        let stats = RelationPartStats {
532            name: "test",
533            metrics: &metrics,
534            stats: &PartStats { key: key_stats },
535            desc: &schema,
536        };
537        assert_eq!(stats.ok_count(), None);
538        assert_eq!(stats.err_count(), None);
539
540        // Well-shaped err stats whose none count exceeds the part length
541        // (corrupt or version-skewed) must read as unknown, not underflow.
542        let mut key_stats = decoder.stats();
543        match key_stats.cols.get_mut("err") {
544            Some(err_stats) => match &mut err_stats.values {
545                ColumnStatKinds::Bytes(BytesStats::Primitive(_)) => {
546                    err_stats.nulls = Some(mz_persist_types::stats::ColumnNullStats {
547                        count: key_stats.len + 1,
548                    });
549                }
550                other => panic!("unexpected err stats {other:?}"),
551            },
552            None => panic!("err stats missing"),
553        }
554        let stats = RelationPartStats {
555            name: "test",
556            metrics: &metrics,
557            stats: &PartStats { key: key_stats },
558            desc: &schema,
559        };
560        assert_eq!(stats.err_count(), None);
561    }
562
563    /// Wrong-shaped ok-column stats must read as "column range unknown",
564    /// which fails open to keeping the part. Both column paths run here: a
565    /// plain column goes through `col_values`, a JSON one adds `col_json`,
566    /// and neither may panic the process reading durable state.
567    #[mz_ore::test]
568    #[cfg_attr(miri, ignore)] // too slow
569    fn malformed_ok_stats_fail_open() {
570        use mz_expr::{BinaryFunc, MirScalarExpr, func};
571        use mz_persist_types::stats::{ColumnNullStats, ColumnarStats, PrimitiveStats};
572        use mz_repr::ReprScalarType;
573
574        let schema = RelationDesc::builder()
575            .with_column("col", SqlScalarType::Int32.nullable(false))
576            .with_column("json", SqlScalarType::Jsonb.nullable(true))
577            .finish();
578        let mut builder = PartBuilder::new(&schema, &UnitSchema);
579        builder.push(
580            &SourceData(Ok(Row::pack_slice(&[Datum::Int32(1), Datum::JsonNull]))),
581            &(),
582            1u64,
583            1i64,
584        );
585        let part = builder.finish();
586        let key_col = part.key.as_struct();
587        let decoder = <RelationDesc as Schema<SourceData>>::decoder(&schema, key_col.clone())
588            .expect("success");
589
590        let json_idx = schema
591            .iter_all()
592            .map(|(idx, _name, _typ)| idx)
593            .nth(1)
594            .expect("two columns");
595        // A filter no Ok row in this part satisfies. With well-shaped stats
596        // the part is skipped, so keeping it proves the fallback ran.
597        let mfp = MapFilterProject::new(2).filter(std::iter::once(MirScalarExpr::CallBinary {
598            func: BinaryFunc::Eq(func::Eq),
599            expr1: Box::new(MirScalarExpr::column(0)),
600            expr2: Box::new(MirScalarExpr::literal_ok(
601                Datum::Int32(999),
602                ReprScalarType::Int32,
603            )),
604        }));
605
606        let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
607        let arena = RowArena::new();
608        let well_shaped = PartStats {
609            key: decoder.stats(),
610        };
611        let well_shaped = RelationPartStats::new("test", &metrics, &schema, &well_shaped);
612        assert!(!well_shaped.may_match_mfp(ResultSpec::anything(), &mfp));
613
614        // `col_values` matched the ok column's kind infallibly. A wrong kind
615        // makes every column's range unknown.
616        let mut not_a_struct = decoder.stats();
617        not_a_struct.cols.insert(
618            "ok".to_string(),
619            ColumnarStats {
620                nulls: Some(ColumnNullStats { count: 0 }),
621                values: PrimitiveStats {
622                    lower: 0i32,
623                    upper: 0i32,
624                }
625                .into(),
626            },
627        );
628        let not_a_struct = PartStats { key: not_a_struct };
629        let not_a_struct = RelationPartStats::new("test", &metrics, &schema, &not_a_struct);
630        for (idx, _name, _typ) in schema.iter_all() {
631            assert_eq!(not_a_struct.col_stats(idx, &arena), ResultSpec::anything());
632        }
633        assert!(not_a_struct.may_match_mfp(ResultSpec::anything(), &mfp));
634
635        // `col_json` additionally required the ok column to be nullable. A
636        // struct that is not widens the JSON range alone, so assert on that
637        // rather than on the part-level decision.
638        let mut not_nullable = decoder.stats();
639        match not_nullable.cols.get_mut("ok") {
640            Some(ok_stats) => ok_stats.nulls = None,
641            None => panic!("ok stats missing"),
642        }
643        let not_nullable = PartStats { key: not_nullable };
644        let not_nullable = RelationPartStats::new("test", &metrics, &schema, &not_nullable);
645        let other_json = Datum::String("a");
646        assert!(
647            !well_shaped
648                .col_stats(json_idx, &arena)
649                .may_contain(other_json)
650        );
651        assert!(
652            not_nullable
653                .col_stats(json_idx, &arena)
654                .may_contain(other_json)
655        );
656    }
657
658    #[mz_ore::test]
659    #[ignore] // TODO(parkmycar): Re-enable this test with a smaller sample size.
660    fn statistics_stability() {
661        /// This is the seed [`proptest`] uses for their deterministic RNG. We
662        /// copy it here to prevent breaking this test if [`proptest`] changes.
663        const RNG_SEED: [u8; 32] = [
664            0xf4, 0x16, 0x16, 0x48, 0xc3, 0xac, 0x77, 0xac, 0x72, 0x20, 0x0b, 0xea, 0x99, 0x67,
665            0x2d, 0x6d, 0xca, 0x9f, 0x76, 0xaf, 0x1b, 0x09, 0x73, 0xa0, 0x59, 0x22, 0x6d, 0xc5,
666            0x46, 0x39, 0x1c, 0x4a,
667        ];
668
669        let rng = proptest::test_runner::TestRng::from_seed(
670            proptest::test_runner::RngAlgorithm::ChaCha,
671            &RNG_SEED,
672        );
673        // Generate a collection of Rows.
674        let config = proptest::test_runner::Config {
675            // We let the loop below drive how much data we generate.
676            cases: u32::MAX,
677            rng_algorithm: proptest::test_runner::RngAlgorithm::ChaCha,
678            ..Default::default()
679        };
680        let mut runner = proptest::test_runner::TestRunner::new_with_rng(config, rng);
681
682        let max_cols = 4;
683        let max_rows = 8;
684        let test_cases = 1000;
685
686        // Note: We don't use the `Arbitrary` impl for `RelationDesc` because
687        // it generates large column names which is not interesting to us.
688        let strat = proptest::collection::vec(any::<SqlColumnType>(), 1..max_cols)
689            .prop_map(|cols| {
690                let col_names = (0..cols.len()).map(|i| i.to_string());
691                RelationDesc::new(SqlRelationType::new(cols), col_names)
692            })
693            .prop_flat_map(|desc| {
694                let rows = desc
695                    .typ()
696                    .columns()
697                    .iter()
698                    .cloned()
699                    .map(arb_datum_for_column)
700                    .collect::<Vec<_>>()
701                    .prop_map(|datums| Row::pack(datums.iter().map(Datum::from)));
702                proptest::collection::vec(rows, 1..max_rows)
703                    .prop_map(move |rows| (desc.clone(), rows))
704            });
705
706        let mut all_stats = Vec::new();
707        for _ in 0..test_cases {
708            let value_tree = strat.new_tree(&mut runner).unwrap();
709            let (desc, rows) = value_tree.current();
710
711            let mut builder = PartBuilder::new(&desc, &UnitSchema);
712            for row in &rows {
713                builder.push(&SourceData(Ok(row.clone())), &(), 1u64, 1i64);
714            }
715            let part = builder.finish();
716
717            let key_col = part.key.as_struct();
718            let decoder = <RelationDesc as Schema<SourceData>>::decoder(&desc, key_col.clone())
719                .expect("success");
720            let key_stats = decoder.stats();
721
722            all_stats.push(key_stats);
723        }
724
725        insta::assert_json_snapshot!(all_stats);
726    }
727}