Skip to content

Commit 1394c7d

Browse files
def-claude
andcommitted
persist, storage, repr: address review feedback
Finish the "fail open on undecodable part stats" change. `LazyPartStats` loses its panicking `decode`, so no read path can reintroduce the panic: the fast-path peek filter keeps the part, `EXPLAIN FILTER PUSHDOWN` reports it as selected, and inspect-state serializes the undecodable marker its `Debug` impl already renders. All three ran on stats straight off durable state, so version skew or corruption panicked clusterd and environmentd respectively. Extend that to the 'ok' column shape checks in `RelationPartStats`, which panicked on a wrong-shaped column on the very call stack the peek filter runs. A mismatch now counts in `mismatched_count` and reports the column range as unknown. Use the whole-second leap time as the interesting `TIME` datum. `from_hms_micro_opt(23, 59, 59, 1_999_999)` is a fractional leap second encoding to 86_400_999_999 microseconds, outside PostgreSQL's [0, 86_400_000_000] `time` domain, where a `postgres-types` client wraps it to 00:00:00.999999. Since `interesting_datums` also feeds the DATUMS load generator, that value reached a user-visible table. It is unreachable from SQL besides, which falsified the premise for keeping the leap representation in `TIME` at all. `TIME '23:59:60'` exercises the same `frac >= 1e9` path and encodes to exactly PostgreSQL's bound. Redact the `DataflowError` in the new error-row audit arm: those events go to Sentry, and `DecodeError` carries the raw source record bytes. Also drop the duplicated `value_between` doc summary line, document that the fuzz runner's interrupted classification treats a kernel OOM SIGKILL as interrupted too, relying on libFuzzer's rss limit to catch memory blowups as artifact-producing OOMs first, and replace the clippy-disallowed `prop_oneof!` and `Iterator::zip` in the new proptest strategies with `Union` and `zip_eq`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0840cfa commit 1394c7d

10 files changed

Lines changed: 204 additions & 59 deletions

File tree

src/adapter/src/coord/sequencer.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,10 +1149,12 @@ pub(crate) async fn explain_pushdown_future_inner<
11491149
let bytes = u64::cast_from(*bytes);
11501150
total_bytes += bytes;
11511151
total_parts += 1u64;
1152-
let selected = match stats {
1152+
let selected = match stats.as_ref().and_then(|x| x.try_decode().ok()) {
1153+
// Also the arm for stats that do not decode, which a
1154+
// newer writer's stats kind can produce. Both report the
1155+
// part as selected, matching what a read of it would do.
11531156
None => true,
11541157
Some(stats) => {
1155-
let stats = stats.decode();
11561158
let stats = RelationPartStats::new(
11571159
name.as_str(),
11581160
&snapshot_stats.metrics.pushdown.part_stats,

src/expr/src/interpret.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ impl<'a> ResultSpec<'a> {
256256
}
257257
}
258258

259-
/// A spec that matches values between the given (non-null) min and max.
260259
/// A spec for the values between `min` and `max` inclusive.
261260
///
262261
/// Unordered bounds widen to [`ResultSpec::value_all`] instead of collapsing

src/persist-client/src/internal/encoding.rs

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,17 +1756,11 @@ impl LazyPartStats {
17561756
/// This does not cache the returned value, it decodes each time it's
17571757
/// called.
17581758
///
1759-
/// Panics if the encoded bytes are malformed. Only call this where the value
1760-
/// is known to have come from `Self::encode` rather than straight off blob.
1761-
pub fn decode(&self) -> PartStats {
1762-
self.try_decode().expect("valid stats")
1763-
}
1764-
1765-
/// Like [Self::decode], but surfaces a malformed encoding as an error.
1766-
///
1767-
/// The bytes are stored undecoded (see the [RustType] impl), so a corrupted
1768-
/// or crafted blob reaches here intact. Anything running on state that has
1769-
/// not been validated yet must use this.
1759+
/// The bytes are stored undecoded (see the [RustType] impl) and are never
1760+
/// validated on the way in, so a corrupted, crafted, or newer-version blob
1761+
/// reaches here intact. There is deliberately no infallible variant: every
1762+
/// caller reads stats straight off durable state, where a decode failure
1763+
/// must fail open (keep the part, report it selected) rather than panic.
17701764
pub fn try_decode(&self) -> Result<PartStats, TryFromProtoError> {
17711765
let key = self
17721766
.key
@@ -2678,15 +2672,12 @@ mod tests {
26782672
LazyPartStats::from_proto(Bytes::from(bytes)).expect("stats bytes are stored undecoded")
26792673
}
26802674

2681-
/// `decode` panics on stats from a newer version, which is why the read
2682-
/// paths (the shard_source filter and the `stats()` accessors in fetch)
2683-
/// must use `try_decode` and fail open to fetching the part.
2684-
#[mz_ore::test]
2685-
#[should_panic(expected = "valid stats")]
2686-
fn part_stats_decode_panics_on_unknown_variant() {
2687-
let _ = version_skewed_part_stats().decode();
2688-
}
2689-
2675+
/// Stats from a newer version are an error rather than a value this
2676+
/// version misreads, which is what lets every read path fail open on
2677+
/// them: the `shard_source` filter and the fast-path peek filter keep the
2678+
/// part, the `stats()` accessors in fetch report `None`, `EXPLAIN FILTER
2679+
/// PUSHDOWN` reports the part as selected, and inspect-state serializes
2680+
/// the stats as absent.
26902681
#[mz_ore::test]
26912682
fn part_stats_try_decode_fails_open_on_unknown_variant() {
26922683
assert_err!(version_skewed_part_stats().try_decode());

src/persist-client/src/internal/state.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2739,8 +2739,19 @@ fn serialize_part_stats<S: Serializer>(
27392739
val: &Option<LazyPartStats>,
27402740
s: S,
27412741
) -> Result<S::Ok, S::Error> {
2742-
let val = val.as_ref().map(|x| x.decode().key);
2743-
val.serialize(s)
2742+
// These bytes come from blob and are never validated on the way in, so a
2743+
// malformed or newer-version encoding reaches here intact. Report it as
2744+
// absent rather than panicking, and keep the field's shape stable for
2745+
// consumers of the inspect-state output by logging the failure instead of
2746+
// serializing a differently typed value in its place.
2747+
let stats = val.as_ref().and_then(|x| match x.try_decode() {
2748+
Ok(stats) => Some(stats.key),
2749+
Err(err) => {
2750+
tracing::warn!("undecodable part stats, reporting as absent: {err}");
2751+
None
2752+
}
2753+
});
2754+
stats.serialize(s)
27442755
}
27452756

27462757
fn serialize_diffs_sum<S: Serializer>(val: &Option<[u8; 8]>, s: S) -> Result<S::Ok, S::Error> {

src/repr/src/scalar.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4113,8 +4113,12 @@ impl SqlScalarType {
41134113
Datum::Time(NaiveTime::from_hms_micro_opt(0, 0, 0, 0).unwrap()),
41144114
Datum::Time(NaiveTime::from_hms_micro_opt(23, 59, 59, 999_999).unwrap()),
41154115
// Leap second: chrono represents it as a fractional part of
4116-
// one second or more.
4117-
Datum::Time(NaiveTime::from_hms_micro_opt(23, 59, 59, 1_999_999).unwrap()),
4116+
// one second or more. `TIME '23:59:60'` is the largest value
4117+
// parsing admits, since fractional leap seconds are rejected,
4118+
// and it encodes to exactly PostgreSQL's 24:00:00 bound. A
4119+
// fractional leap second here would leave the type's
4120+
// PostgreSQL wire domain.
4121+
Datum::Time(NaiveTime::from_hms_micro_opt(23, 59, 59, 1_000_000).unwrap()),
41184122
])
41194123
});
41204124
static TIMESTAMP: LazyLock<Row> = LazyLock::new(|| {

src/storage-operators/src/persist_source.rs

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -711,10 +711,15 @@ impl PendingWork {
711711
sentry::with_scope(
712712
|scope| scope.set_tag("alert_id", "persist_pushdown_audit_violation"),
713713
|| {
714+
// `err` is redacted for the same reason the
715+
// `Ok`-row arm redacts its MFP output: these
716+
// events go to Sentry, and a `DecodeError`
717+
// carries the raw source record bytes while
718+
// several `EvalError`s embed user input.
714719
error!(
715720
?stats,
716721
name,
717-
?err,
722+
err = ?redact(&err),
718723
"persist filter pushdown correctness violation!"
719724
);
720725
if self.panic_on_audit_failure {
@@ -1534,6 +1539,7 @@ mod tests {
15341539
/// column stat range that fails to contain a real value). See
15351540
/// database-issues#9656 / PER-50.
15361541
mod filter_pushdown_audit {
1542+
use itertools::Itertools;
15371543
use mz_expr::func::variadic::{And, Or};
15381544
use mz_expr::func::{
15391545
AddFloat32, AddTimestampInterval, CastNumericToFloat32, CastNumericToMzTimestamp, Eq,
@@ -1549,6 +1555,7 @@ mod tests {
15491555
use mz_repr::{Diff, ReprScalarType, SqlScalarType};
15501556
use proptest::prelude::*;
15511557
use proptest::sample::{Index, select};
1558+
use proptest::strategy::Union;
15521559

15531560
use super::*;
15541561

@@ -1945,14 +1952,14 @@ mod tests {
19451952
let ok_row = prop::collection::vec(any::<Index>(), WIDE_ARITY).prop_map(move |picks| {
19461953
let datums = picks
19471954
.iter()
1948-
.zip(&pools)
1955+
.zip_eq(&pools)
19491956
.map(|(pick, pool)| pool[pick.index(pool.len())]);
19501957
SourceData(Ok(Row::pack(datums)))
19511958
});
19521959
let err_row = Just(SourceData(Err(DataflowError::from(
19531960
EvalError::DivisionByZero,
19541961
))));
1955-
let row = prop_oneof![9 => ok_row, 1 => err_row];
1962+
let row = Union::new_weighted(vec![(9, ok_row.boxed()), (1, err_row.boxed())]);
19561963
prop::collection::vec(row, 2..8)
19571964
}
19581965

@@ -2154,37 +2161,47 @@ mod tests {
21542161
}
21552162

21562163
fn arb_wide_predicate() -> impl Strategy<Value = MirScalarExpr> {
2157-
let leaf = prop_oneof![
2158-
arb_cmp_col_lit(),
2159-
arb_is_null_pred(),
2160-
arb_jsonb_pred(),
2161-
arb_case_jsonb_pred(),
2162-
arb_iso_parse_pred(),
2163-
arb_ts_interval_pred(),
2164-
arb_float_mul_pred(),
2165-
arb_temporal_pred(),
2164+
let leaf = Union::new(vec![
2165+
arb_cmp_col_lit().boxed(),
2166+
arb_is_null_pred().boxed(),
2167+
arb_jsonb_pred().boxed(),
2168+
arb_case_jsonb_pred().boxed(),
2169+
arb_iso_parse_pred().boxed(),
2170+
arb_ts_interval_pred().boxed(),
2171+
arb_float_mul_pred().boxed(),
2172+
arb_temporal_pred().boxed(),
21662173
// The numeric shapes from the narrow test, aimed at the
21672174
// fallible-interior mechanisms; both reference column 0.
21682175
(
21692176
select(vec![0i32, 2, -5, 24699]),
21702177
f32_consts(),
21712178
f32_consts(),
21722179
f32_consts(),
2173-
comparison_funcs()
2180+
comparison_funcs(),
21742181
)
2175-
.prop_map(|(s, a, b, c, cmp)| float_arith_predicate(s, a, b, c, cmp)),
2182+
.prop_map(|(s, a, b, c, cmp)| float_arith_predicate(s, a, b, c, cmp))
2183+
.boxed(),
21762184
(select(vec![0u64, 1, 2, 100, u64::MAX]), comparison_funcs())
2177-
.prop_map(|(ts, cmp)| cast_mz_timestamp_predicate(ts, cmp)),
2178-
]
2185+
.prop_map(|(ts, cmp)| cast_mz_timestamp_predicate(ts, cmp))
2186+
.boxed(),
2187+
])
21792188
.boxed();
2180-
prop_oneof![
2181-
3 => leaf.clone(),
2182-
1 => (leaf.clone(), leaf.clone(), any::<bool>()).prop_map(|(a, b, is_and)| {
2183-
let func = if is_and { And.into() } else { Or.into() };
2184-
MirScalarExpr::CallVariadic { func, exprs: vec![a, b] }
2185-
}),
2186-
1 => leaf.prop_map(not),
2187-
]
2189+
Union::new_weighted(vec![
2190+
(3, leaf.clone()),
2191+
(
2192+
1,
2193+
(leaf.clone(), leaf.clone(), any::<bool>())
2194+
.prop_map(|(a, b, is_and)| {
2195+
let func = if is_and { And.into() } else { Or.into() };
2196+
MirScalarExpr::CallVariadic {
2197+
func,
2198+
exprs: vec![a, b],
2199+
}
2200+
})
2201+
.boxed(),
2202+
),
2203+
(1, leaf.prop_map(not).boxed()),
2204+
])
21882205
}
21892206

21902207
/// The zero-column count(*) path: when the read desc projects away

src/storage-operators/src/stats.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ impl StatsCursor {
4848
let should_fetch = |name: &'static str, errors: bool| {
4949
move |stats: Option<&LazyPartStats>| {
5050
let Some(stats) = stats else { return true };
51-
let stats = stats.decode();
51+
// Stats written by a newer version may not decode. The sound
52+
// fallback is to fetch the part.
53+
let Ok(stats) = stats.try_decode() else {
54+
return true;
55+
};
5256
let metrics = &metrics.pushdown.part_stats;
5357
let relation_stats = RelationPartStats::new(name, metrics, desc, &stats);
5458
if errors {

src/storage-types/src/stats.rs

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,20 @@ impl RelationPartStats<'_> {
124124
let typ = &self.desc.get_type(idx);
125125

126126
let ok_stats = self.stats.key.col("ok")?;
127-
let ok_stats = ok_stats
128-
.try_as_optional_struct()
129-
.expect("ok column should be nullable struct");
127+
// These stats come straight off durable state, so a corrupt or
128+
// version-skewed encoding can carry any shape here. Report the column
129+
// range as unknown rather than panicking the process reading it.
130+
let ok_stats = match ok_stats.try_as_optional_struct() {
131+
Ok(ok_stats) => ok_stats,
132+
Err(err) => {
133+
self.metrics.mismatched_count.inc();
134+
tracing::error!(
135+
"expected nullable struct stats for the 'ok' column of {}: {err}",
136+
self.name
137+
);
138+
return None;
139+
}
140+
};
130141
let col_stats = ok_stats.some.cols.get(name.as_str())?;
131142

132143
if let SqlColumnType {
@@ -200,8 +211,16 @@ impl RelationPartStats<'_> {
200211
let typ = self.desc.get_type(idx);
201212

202213
let ok_stats = self.stats.key.cols.get("ok")?;
214+
// See the note in `col_json`: durable stats can be any shape, so a
215+
// wrong-shaped 'ok' column makes the range unknown, not a panic.
203216
let ColumnStatKinds::Struct(ok_stats) = &ok_stats.values else {
204-
panic!("'ok' column stats should be a struct")
217+
self.metrics.mismatched_count.inc();
218+
tracing::error!(
219+
"expected struct stats for the 'ok' column of {}, found {:?}",
220+
self.name,
221+
ok_stats.values
222+
);
223+
return None;
205224
};
206225
let col_stats = ok_stats.cols.get(name.as_str())?;
207226

@@ -516,6 +535,101 @@ mod tests {
516535
assert_eq!(stats.err_count(), None);
517536
}
518537

538+
/// Wrong-shaped ok-column stats must read as "column range unknown",
539+
/// which fails open to keeping the part. Both column paths run here: a
540+
/// plain column goes through `col_values`, a JSON one adds `col_json`,
541+
/// and neither may panic the process reading durable state.
542+
#[mz_ore::test]
543+
#[cfg_attr(miri, ignore)] // too slow
544+
fn malformed_ok_stats_fail_open() {
545+
use mz_expr::{BinaryFunc, MirScalarExpr, func};
546+
use mz_persist_types::stats::{ColumnNullStats, ColumnarStats, PrimitiveStats};
547+
use mz_repr::ReprScalarType;
548+
549+
let schema = RelationDesc::builder()
550+
.with_column("col", SqlScalarType::Int32.nullable(false))
551+
.with_column("json", SqlScalarType::Jsonb.nullable(true))
552+
.finish();
553+
let mut builder = PartBuilder::new(&schema, &UnitSchema);
554+
builder.push(
555+
&SourceData(Ok(Row::pack_slice(&[Datum::Int32(1), Datum::JsonNull]))),
556+
&(),
557+
1u64,
558+
1i64,
559+
);
560+
let part = builder.finish();
561+
let key_col = part.key.as_struct();
562+
let decoder = <RelationDesc as Schema<SourceData>>::decoder(&schema, key_col.clone())
563+
.expect("success");
564+
565+
let json_idx = schema
566+
.iter_all()
567+
.map(|(idx, _name, _typ)| idx)
568+
.nth(1)
569+
.expect("two columns");
570+
// A filter no Ok row in this part satisfies. With well-shaped stats
571+
// the part is skipped, so keeping it proves the fallback ran.
572+
let mfp = MapFilterProject::new(2).filter(std::iter::once(MirScalarExpr::CallBinary {
573+
func: BinaryFunc::Eq(func::Eq),
574+
expr1: Box::new(MirScalarExpr::column(0)),
575+
expr2: Box::new(MirScalarExpr::literal_ok(
576+
Datum::Int32(999),
577+
ReprScalarType::Int32,
578+
)),
579+
}));
580+
581+
let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
582+
let arena = RowArena::new();
583+
let well_shaped = PartStats {
584+
key: decoder.stats(),
585+
};
586+
let well_shaped = RelationPartStats::new("test", &metrics, &schema, &well_shaped);
587+
assert!(!well_shaped.may_match_mfp(ResultSpec::anything(), &mfp));
588+
589+
// `col_values` matched the ok column's kind infallibly. A wrong kind
590+
// makes every column's range unknown.
591+
let mut not_a_struct = decoder.stats();
592+
not_a_struct.cols.insert(
593+
"ok".to_string(),
594+
ColumnarStats {
595+
nulls: Some(ColumnNullStats { count: 0 }),
596+
values: PrimitiveStats {
597+
lower: 0i32,
598+
upper: 0i32,
599+
}
600+
.into(),
601+
},
602+
);
603+
let not_a_struct = PartStats { key: not_a_struct };
604+
let not_a_struct = RelationPartStats::new("test", &metrics, &schema, &not_a_struct);
605+
for (idx, _name, _typ) in schema.iter_all() {
606+
assert_eq!(not_a_struct.col_stats(idx, &arena), ResultSpec::anything());
607+
}
608+
assert!(not_a_struct.may_match_mfp(ResultSpec::anything(), &mfp));
609+
610+
// `col_json` additionally required the ok column to be nullable. A
611+
// struct that is not widens the JSON range alone, so assert on that
612+
// rather than on the part-level decision.
613+
let mut not_nullable = decoder.stats();
614+
match not_nullable.cols.get_mut("ok") {
615+
Some(ok_stats) => ok_stats.nulls = None,
616+
None => panic!("ok stats missing"),
617+
}
618+
let not_nullable = PartStats { key: not_nullable };
619+
let not_nullable = RelationPartStats::new("test", &metrics, &schema, &not_nullable);
620+
let other_json = Datum::String("a");
621+
assert!(
622+
!well_shaped
623+
.col_stats(json_idx, &arena)
624+
.may_contain(other_json)
625+
);
626+
assert!(
627+
not_nullable
628+
.col_stats(json_idx, &arena)
629+
.may_contain(other_json)
630+
);
631+
}
632+
519633
#[mz_ore::test]
520634
#[ignore] // TODO(parkmycar): Re-enable this test with a smaller sample size.
521635
fn statistics_stability() {

test/cargo-fuzz/mzcompose.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,10 @@ def _reap(self, job: Job) -> None:
437437
):
438438
# Killed by a signal (Ctrl-C, step timeout, an external kill)
439439
# without a crash artifact: an interrupted run, not a crash.
440-
# Crashes always leave an artifact, so this cannot mask one.
440+
# libFuzzer-detected crashes always leave an artifact, so this
441+
# cannot mask one. A kernel OOM SIGKILL is also reported as
442+
# interrupted; the rss limit passed to libFuzzer catches memory
443+
# blowups as artifact-producing OOMs well before the kernel does.
441444
self.succeeded.append(job)
442445
say(
443446
f"- {job.name} interrupted by signal {-job.returncode} [{secs}s] "

0 commit comments

Comments
 (0)