Skip to content

Commit c461abe

Browse files
def-claude
andcommitted
expr: don't read unordered stats bounds as an empty range (PER-53)
`ResultSpec::value_between` collapsed `min > max` to `nothing()`, i.e. "no value can be here". That only holds for bounds a caller derived coherently. Persist part statistics are not such a caller: arrow orders floats totally, putting `-NaN` below `-Infinity`, while `OrderedFloat`, the `Datum` order `value_between` compares in, ranks every NaN above every finite value. A part holding `-NaN` therefore reports `lower = -NaN` against a finite `upper`, the range read back as empty, and filter pushdown discarded the part, losing every other row in it. `'-NaN'` is ordinary user input, so no corrupt storage is needed to reach this. Widening unordered bounds is not sufficient on its own. A part holding NaNs of both signs records the total-order bounds `(-NaN, +NaN)`, which decode to `NaN == NaN`: a seemingly valid, non-inverted range claiming the part holds nothing but NaN, so a filter matching a finite row still discarded the part. Only the stats decode still sees the NaN signs, so `col_values` additionally refuses to produce float bounds from a negative-NaN lower, unless the upper shows the whole column is NaN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5GiWioZM6CWrwWgfBADDR
1 parent 0a39050 commit c461abe

4 files changed

Lines changed: 232 additions & 3 deletions

File tree

‎src/expr/src/interpret.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,14 @@ impl<'a> ResultSpec<'a> {
257257
}
258258

259259
/// A spec that matches values between the given (non-null) min and max.
260+
/// A spec for the values between `min` and `max` inclusive.
261+
///
262+
/// Unordered bounds widen to [`ResultSpec::value_all`] instead of collapsing
263+
/// to [`ResultSpec::nothing`]: they mean the bounds are unusable, not that
264+
/// the column holds nothing. Persist float stats produce them, because arrow
265+
/// orders floats totally, putting `-NaN` below `-Infinity`, while the
266+
/// [`Datum`] order compared here ranks every NaN above every finite value.
267+
/// Collapsing lost every other row in such a part (PER-53).
260268
pub fn value_between(min: Datum<'a>, max: Datum<'a>) -> ResultSpec<'a> {
261269
assert!(!min.is_null());
262270
assert!(!max.is_null());
@@ -266,7 +274,7 @@ impl<'a> ResultSpec<'a> {
266274
..ResultSpec::nothing()
267275
}
268276
} else {
269-
ResultSpec::nothing()
277+
ResultSpec::value_all()
270278
}
271279
}
272280

‎src/repr/src/stats.rs‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ pub fn fixed_stats_from_column(
9696
.into()
9797
}
9898

99+
/// Persist computes float column bounds in IEEE-754 total order, in which
100+
/// negative NaNs sort below -Infinity. `Datum` floats compare via
101+
/// `OrderedFloat`, which ranks every NaN (of either sign) above every other
102+
/// value. A lower bound that is a negative NaN therefore admits both NaNs
103+
/// (the largest values under `Datum` ordering) and ordinary values up to
104+
/// `upper`, a set no `Datum` interval can bound, so no bounds are returned
105+
/// and the column is treated as unconstrained. The one exception is an upper
106+
/// bound that is also a negative NaN, which under total order means every
107+
/// value in the column is a NaN.
108+
///
109+
/// NOTE: the widening `ResultSpec::value_between` does for inverted bounds is
110+
/// not sufficient here. A part holding both `-NaN` and `+NaN` decodes to the
111+
/// non-inverted bounds `(NaN, NaN)`, which would wrongly claim the part holds
112+
/// nothing but NaN. Only this layer still sees the NaN signs.
113+
fn float_bounds<F: num_traits::Float>(lower: F, upper: F) -> Option<(F, F)> {
114+
if lower.is_nan() && lower.is_sign_negative() && !(upper.is_nan() && upper.is_sign_negative()) {
115+
None
116+
} else {
117+
Some((lower, upper))
118+
}
119+
}
120+
99121
/// Returns a `(lower, upper)` bound from the provided [`ColumnStatKinds`], if applicable.
100122
pub fn col_values<'a>(
101123
typ: &SqlScalarType,
@@ -145,10 +167,18 @@ pub fn col_values<'a>(
145167
map_stats(stats, Datum::Int64)
146168
}
147169
(SqlScalarType::Float32, ColumnStatKinds::Primitive(F32(stats))) => {
148-
map_stats(stats, |x| Datum::Float32(OrderedFloat(x)))
170+
let (lower, upper) = float_bounds(stats.lower, stats.upper)?;
171+
Some((
172+
Datum::Float32(OrderedFloat(lower)),
173+
Datum::Float32(OrderedFloat(upper)),
174+
))
149175
}
150176
(SqlScalarType::Float64, ColumnStatKinds::Primitive(F64(stats))) => {
151-
map_stats(stats, |x| Datum::Float64(OrderedFloat(x)))
177+
let (lower, upper) = float_bounds(stats.lower, stats.upper)?;
178+
Some((
179+
Datum::Float64(OrderedFloat(lower)),
180+
Datum::Float64(OrderedFloat(upper)),
181+
))
152182
}
153183
(
154184
SqlScalarType::Numeric { .. },

‎src/storage-operators/src/persist_source.rs‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,6 +1529,10 @@ mod tests {
15291529
MirScalarExpr::literal_ok(Datum::from(x), ReprScalarType::Float32)
15301530
}
15311531

1532+
fn f64_lit(x: f64) -> MirScalarExpr {
1533+
MirScalarExpr::literal_ok(Datum::from(x), ReprScalarType::Float64)
1534+
}
1535+
15321536
fn numeric_datum(x: f64) -> Datum<'static> {
15331537
Datum::from(Numeric::from(x))
15341538
}
@@ -1713,5 +1717,130 @@ mod tests {
17131717
check(rows, predicate)?;
17141718
});
17151719
}
1720+
1721+
/// Assert that `filter_result` keeps a part it must keep, and that the
1722+
/// case is not vacuous: the MFP has to yield output on a real row for
1723+
/// "must keep" to mean anything.
1724+
fn assert_part_kept(desc: &RelationDesc, rows: &[Row], predicate: MirScalarExpr) {
1725+
let plan = MapFilterProject::new(1)
1726+
.filter(std::iter::once(predicate))
1727+
.into_plan()
1728+
.expect("into_plan");
1729+
assert!(
1730+
mfp_yields_output(&plan, rows),
1731+
"nothing to keep: the MFP yields no output on any of these rows.\n\
1732+
rows={rows:?}\nplan={plan:?}",
1733+
);
1734+
1735+
let part_stats = build_part_stats(desc, rows);
1736+
let metrics = PartStatsMetrics::new(&MetricsRegistry::new());
1737+
let stats = RelationPartStats::new("test", &metrics, desc, &part_stats);
1738+
let decision = filter_result(desc, ResultSpec::anything(), stats, &plan);
1739+
assert!(
1740+
!matches!(decision, FilterResult::Discard),
1741+
"filter pushdown discarded a part whose MFP yields output on a real row.\n\
1742+
rows={rows:?}\nplan={plan:?}",
1743+
);
1744+
}
1745+
1746+
/// A part holding `-NaN` must not hide the rest of its rows from a
1747+
/// filter on that column.
1748+
///
1749+
/// `PrimitiveStats` takes a float column's bounds in arrow's *total*
1750+
/// order, where `-NaN` sorts below `-Infinity`, so such a part records
1751+
/// `lower = -NaN` against a finite `upper`. `OrderedFloat`, the `Datum`
1752+
/// order the interpreter compares in, ranks every NaN *above* every
1753+
/// finite value, so those bounds arrive unordered. Reading them as an
1754+
/// empty range discarded the part, which lost every other row in it
1755+
/// (PER-53). Both float widths take the same path.
1756+
#[mz_ore::test]
1757+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function
1758+
fn negative_nan_does_not_discard_matching_part() {
1759+
// Both rows have to land in the same part. Split across parts each
1760+
// one gets ordered bounds of its own and nothing is discarded.
1761+
let desc = RelationDesc::builder()
1762+
.with_column("c0", SqlScalarType::Float32.nullable(false))
1763+
.finish();
1764+
let rows = [
1765+
Row::pack_slice(&[Datum::from(-f32::NAN)]),
1766+
Row::pack_slice(&[Datum::from(0.0f32)]),
1767+
];
1768+
assert_part_kept(
1769+
&desc,
1770+
&rows,
1771+
MirScalarExpr::CallBinary {
1772+
func: BinaryFunc::Lt(Lt),
1773+
expr1: Box::new(MirScalarExpr::column(0)),
1774+
expr2: Box::new(f32_lit(1.0)),
1775+
},
1776+
);
1777+
1778+
let desc = RelationDesc::builder()
1779+
.with_column("c0", SqlScalarType::Float64.nullable(false))
1780+
.finish();
1781+
let rows = [
1782+
Row::pack_slice(&[Datum::from(-f64::NAN)]),
1783+
Row::pack_slice(&[Datum::from(0.0f64)]),
1784+
];
1785+
assert_part_kept(
1786+
&desc,
1787+
&rows,
1788+
MirScalarExpr::CallBinary {
1789+
func: BinaryFunc::Lt(Lt),
1790+
expr1: Box::new(MirScalarExpr::column(0)),
1791+
expr2: Box::new(f64_lit(1.0)),
1792+
},
1793+
);
1794+
}
1795+
1796+
/// A part holding NaNs of both signs must not hide its other rows.
1797+
///
1798+
/// Arrow's total order puts `-NaN` below and `+NaN` above everything,
1799+
/// so such a part records the bounds `(-NaN, +NaN)`. Under
1800+
/// `OrderedFloat` those decode to `NaN == NaN`: not an inverted range
1801+
/// but a seemingly valid one claiming the part holds nothing but NaN,
1802+
/// so widening unordered bounds does not catch it. A filter that a
1803+
/// finite row matches but NaN does not then discarded the part. Only
1804+
/// the stats decode still sees the NaN signs, so the guard lives in
1805+
/// `mz_repr::stats::col_values`.
1806+
#[mz_ore::test]
1807+
#[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function
1808+
fn mixed_sign_nans_do_not_discard_matching_part() {
1809+
let desc = RelationDesc::builder()
1810+
.with_column("c0", SqlScalarType::Float32.nullable(false))
1811+
.finish();
1812+
let rows = [
1813+
Row::pack_slice(&[Datum::from(-f32::NAN)]),
1814+
Row::pack_slice(&[Datum::from(f32::NAN)]),
1815+
Row::pack_slice(&[Datum::from(0.0f32)]),
1816+
];
1817+
assert_part_kept(
1818+
&desc,
1819+
&rows,
1820+
MirScalarExpr::CallBinary {
1821+
func: BinaryFunc::Eq(Eq),
1822+
expr1: Box::new(MirScalarExpr::column(0)),
1823+
expr2: Box::new(f32_lit(0.0)),
1824+
},
1825+
);
1826+
1827+
let desc = RelationDesc::builder()
1828+
.with_column("c0", SqlScalarType::Float64.nullable(false))
1829+
.finish();
1830+
let rows = [
1831+
Row::pack_slice(&[Datum::from(-f64::NAN)]),
1832+
Row::pack_slice(&[Datum::from(f64::NAN)]),
1833+
Row::pack_slice(&[Datum::from(0.0f64)]),
1834+
];
1835+
assert_part_kept(
1836+
&desc,
1837+
&rows,
1838+
MirScalarExpr::CallBinary {
1839+
func: BinaryFunc::Eq(Eq),
1840+
expr1: Box::new(MirScalarExpr::column(0)),
1841+
expr2: Box::new(f64_lit(0.0)),
1842+
},
1843+
);
1844+
}
17161845
}
17171846
}

‎test/sqllogictest/explain/pushdown.slt‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,68 @@ WHERE ((CASE WHEN flag THEN j1 ELSE j2 END) ->> 'y') IS NULL
123123
----
124124
3
125125

126+
# Regression test for PER-53: A negative NaN must not poison the stats of the
127+
# float column it lands in.
128+
129+
simple conn=mz_system,user=mz_system
130+
ALTER SYSTEM SET persist_stats_audit_percent = 0
131+
----
132+
COMPLETE 0
133+
134+
statement ok
135+
CREATE TABLE floats (value double precision)
136+
137+
# One INSERT, so both rows land in the same part and share its stats.
138+
statement ok
139+
INSERT INTO floats VALUES ('-NaN'), (1.0)
140+
141+
# The part holds a matching row, so it must be selected.
142+
query TIIII
143+
EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM floats WHERE value = 1.0::double precision
144+
----
145+
materialize.public.floats 1344 1344 1 1
146+
147+
query I
148+
SELECT count(*) FROM floats WHERE value = 1.0::double precision
149+
----
150+
1
151+
152+
# The same part, read through a dataflow rather than a one-shot peek.
153+
statement ok
154+
CREATE MATERIALIZED VIEW ones AS
155+
SELECT value FROM floats WHERE value = 1.0::double precision
156+
157+
query I
158+
SELECT count(*) FROM ones
159+
----
160+
1
161+
162+
# Variant with both NaN signs in one part: the bounds decode to the
163+
# non-inverted range (NaN, NaN), so widening inverted bounds alone does not
164+
# catch it. The spec then claimed the part holds nothing but NaN and a filter
165+
# on a finite value pruned the part, hiding the 0.0 row.
166+
167+
statement ok
168+
CREATE TABLE mixed_nan (value double precision)
169+
170+
statement ok
171+
INSERT INTO mixed_nan VALUES ('-NaN'), ('NaN'), (0.0)
172+
173+
query TIIII
174+
EXPLAIN FILTER PUSHDOWN FOR SELECT * FROM mixed_nan WHERE value = 0.0::double precision
175+
----
176+
materialize.public.mixed_nan 1355 1355 1 1
177+
178+
query I
179+
SELECT count(*) FROM mixed_nan WHERE value = 0.0::double precision
180+
----
181+
1
182+
183+
simple conn=mz_system,user=mz_system
184+
ALTER SYSTEM RESET persist_stats_audit_percent
185+
----
186+
COMPLETE 0
187+
126188
# Leap-second timestamps roll over at parse time (Postgres compat). The
127189
# chrono leap representation sorted below the next second while the epoch
128190
# family (extract epoch, casts to mz_timestamp, subtraction, date_bin)

0 commit comments

Comments
 (0)