Skip to content

Commit 785e5d4

Browse files
def-claude
andcommitted
expr: preserve operand errors in non-strict AND/OR reduce
Closes: CLU-137 Fixes two user-visible bugs in how `reduce` treats an erroring operand of a non-strict AND/OR: * A materialized view or query whose `mz_now()` temporal filter sits under an OR of ANDs, e.g. WHERE (a AND ... AND mz_now() < t) OR (b AND ... AND mz_now() < t) could panic the compute worker (or fail to plan) with "Unsupported temporal predicate". The shared `mz_now() < t` conjunct was left buried inside the OR instead of being factored out, so temporal-filter extraction failed. * A query that should short-circuit, such as `WHERE col AND (1/0 = 1)`, could spuriously fail at runtime (e.g. "division by zero") even on rows where `col` is false. AND/OR are non-strict: `false AND <error>` is `false` and `true OR <error>` is `true`, so such rows must be filtered, not errored. Mechanism: the generic variadic fold in `reduce` replaced a call with any operand's literal error unconditionally, which is wrong for a function that is not strict in errors. Fold only for the strict variadics, excluding AND/OR and ErrorIfNull (which can absorb an operand's error at runtime). Keeping the erroring operand then reaches `undistribute_and_or`, which recombines operands across AND/OR's short-circuit boundary. That only preserves error semantics for operands common to every disjunct, so skip undistribution otherwise. A shared temporal predicate (`mz_now() < t`, whose cast can error) is common to every disjunct, so it is still factored out and stays extractable, unlike the reverted #37049's blunt `could_error()` skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25802a5 commit 785e5d4

5 files changed

Lines changed: 227 additions & 5 deletions

File tree

src/compute/src/render/context.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,13 @@ impl<'scope, T: RenderTimestamp> CollectionBundle<'scope, T> {
938938
VecCollection<'scope, T, DataflowErrorSer, Diff>,
939939
) {
940940
mfp.optimize();
941-
let mfp_plan = mfp.clone().into_plan().unwrap();
941+
// NOTE: only an un-extractable temporal predicate makes `into_plan` fail;
942+
// `ExprPrepMaintained` rejects those at plan time and peeks materialize
943+
// `mz_now()`, so none reach render.
944+
let mfp_plan = mfp
945+
.clone()
946+
.into_plan()
947+
.expect("temporal predicate extractable");
942948

943949
// If the MFP is trivial, we can just call `as_collection`.
944950
// In the case that we weren't going to apply the `key_val` optimization,
@@ -959,7 +965,7 @@ impl<'scope, T: RenderTimestamp> CollectionBundle<'scope, T> {
959965
let max_demand = mfp.demand().last().map(|x| *x + 1).unwrap_or(0);
960966
mfp.permute_fn(|c| c, max_demand);
961967
mfp.optimize();
962-
let mfp_plan = mfp.into_plan().unwrap();
968+
let mfp_plan = mfp.into_plan().expect("temporal predicate extractable");
963969

964970
let mut datum_vec = DatumVec::new();
965971
// Wrap in an `Rc` so that lifetimes work out.

src/expr/src/scalar.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,49 @@ impl MirScalarExpr {
849849
}
850850
}
851851

852+
/// Whether [`Self::undistribute_and_or`] can rewrite `self` without changing
853+
/// its error semantics.
854+
///
855+
/// Undistribution recombines the operands *not* common to every disjunct into
856+
/// a new AND/OR, changing the short-circuit context they evaluate in. Only a
857+
/// dominating operand (`false`/`true`), not a NULL one, absorbs another
858+
/// operand's error, so recombining a could-error operand can change whether a
859+
/// row errors (e.g. `a OR (a AND <err>)` raises for a NULL `a`, but
860+
/// undistributes to `a`). Operands common to every disjunct are factored out
861+
/// unchanged and stay sound, which is what lets a shared temporal filter like
862+
/// `mz_now() < <expr>` (whose cast can error) be factored out, as the renderer
863+
/// requires.
864+
fn undistribution_preserves_errors(&self) -> bool {
865+
let MirScalarExpr::CallVariadic {
866+
func: func @ (VariadicFunc::And(_) | VariadicFunc::Or(_)),
867+
exprs,
868+
} = self
869+
else {
870+
return true;
871+
};
872+
// Fast path for the common case: with no erroring operand there is
873+
// nothing to preserve, so skip the per-operand bookkeeping below.
874+
// `could_error` recurses, so this also covers nested operands.
875+
if !exprs.iter().any(|o| o.could_error()) {
876+
return true;
877+
}
878+
let inner = func.switch_and_or();
879+
// The operands of a disjunct (a non-`inner` disjunct is its own singleton).
880+
let operands = |o: &MirScalarExpr| match o {
881+
MirScalarExpr::CallVariadic { func, exprs } if *func == inner => exprs.clone(),
882+
_ => vec![o.clone()],
883+
};
884+
let common = exprs
885+
.iter()
886+
.map(|o| BTreeSet::from_iter(operands(o)))
887+
.reduce(|a, b| &a & &b)
888+
.unwrap_or_default();
889+
exprs
890+
.iter()
891+
.flat_map(operands)
892+
.all(|op| !op.could_error() || common.contains(&op))
893+
}
894+
852895
/// AND/OR undistribution (factoring out) to apply at each `MirScalarExpr`.
853896
///
854897
/// This method attempts to apply one of the [distribution laws][distributivity]
@@ -922,6 +965,11 @@ impl MirScalarExpr {
922965
while old_self != *self {
923966
old_self = self.clone();
924967
self.reduce_and_canonicalize_and_or(); // We don't want to deal with 1-arg AND/OR at the top
968+
969+
if !self.undistribution_preserves_errors() {
970+
return;
971+
}
972+
925973
if let MirScalarExpr::CallVariadic {
926974
exprs: outer_operands,
927975
func: outer_func @ (VariadicFunc::Or(_) | VariadicFunc::And(_)),
@@ -2475,6 +2523,78 @@ mod tests {
24752523
use super::*;
24762524
use crate::scalar::func::variadic::Coalesce;
24772525

2526+
#[mz_ore::test]
2527+
fn test_reduce_and_or_preserves_operand_errors() {
2528+
// #37049: AND/OR are non-strict, so a `false`/`true` operand absorbs an
2529+
// erroring operand at runtime. `reduce` must not fold or absorb an
2530+
// erroring operand into an error the original never raises.
2531+
let bool_typ = ReprScalarType::Bool;
2532+
let types = vec![bool_typ.clone().nullable(true)];
2533+
let err = || MirScalarExpr::literal(Err(EvalError::DivisionByZero), bool_typ.clone());
2534+
let col = || MirScalarExpr::column(0);
2535+
let arena = RowArena::new();
2536+
2537+
// `x AND <err>` must not fold to the error: `false AND <err>` is `false`.
2538+
let mut and = col().and(err());
2539+
and.reduce(&types);
2540+
assert!(!and.is_literal_err(), "{and:?}");
2541+
assert_eq!(and.eval(&[Datum::False], &arena), Ok(Datum::False));
2542+
2543+
// `a OR (a AND <err>)` must not absorb to `a`: a NULL `a` surfaces the
2544+
// error (`NULL OR (NULL AND <err>)` = `<err>`).
2545+
let mut or = col().or(col().and(err()));
2546+
or.reduce(&types);
2547+
assert_ne!(or, col(), "{or:?}");
2548+
assert_eq!(or.eval(&[Datum::True], &arena), Ok(Datum::True));
2549+
assert_eq!(or.eval(&[Datum::False], &arena), Ok(Datum::False));
2550+
assert_eq!(
2551+
or.eval(&[Datum::Null], &arena),
2552+
Err(EvalError::DivisionByZero)
2553+
);
2554+
}
2555+
2556+
#[mz_ore::test]
2557+
fn test_reduce_and_or_undistributes_common_error() {
2558+
// CLU-137: undistribution must still factor out a could-error operand
2559+
// common to every disjunct (e.g. a temporal `mz_now() < <expr>`), so it
2560+
// becomes a standalone conjunct the renderer can extract. The reverted
2561+
// #37049 guard skipped undistribution for any could-error expression,
2562+
// burying such predicates inside the OR.
2563+
let bool_typ = ReprScalarType::Bool;
2564+
let types = vec![
2565+
bool_typ.clone().nullable(true),
2566+
bool_typ.clone().nullable(true),
2567+
];
2568+
let err = || MirScalarExpr::literal(Err(EvalError::DivisionByZero), bool_typ.clone());
2569+
let arena = RowArena::new();
2570+
2571+
// `(a AND <err>) OR (b AND <err>)` --> `<err> AND (a OR b)`.
2572+
let original = MirScalarExpr::column(0)
2573+
.and(err())
2574+
.or(MirScalarExpr::column(1).and(err()));
2575+
let mut reduced = original.clone();
2576+
reduced.reduce(&types);
2577+
assert!(
2578+
matches!(
2579+
&reduced,
2580+
MirScalarExpr::CallVariadic {
2581+
func: VariadicFunc::And(_),
2582+
..
2583+
}
2584+
),
2585+
"common error operand not factored out: {reduced:?}"
2586+
);
2587+
// ...and error semantics are preserved over every assignment.
2588+
for a in [Datum::True, Datum::False, Datum::Null] {
2589+
for b in [Datum::True, Datum::False, Datum::Null] {
2590+
assert_eq!(
2591+
reduced.eval(&[a, b], &arena),
2592+
original.eval(&[a, b], &arena)
2593+
);
2594+
}
2595+
}
2596+
}
2597+
24782598
#[mz_ore::test]
24792599
#[cfg_attr(miri, ignore)] // error: unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
24802600
fn test_reduce() {

src/expr/src/scalar/reduce/variadic.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,22 @@ pub(super) fn reduce_call_variadic(
5151
*e = MirScalarExpr::literal_null(e.typ(column_types).scalar_type);
5252
return;
5353
}
54-
if let Some(err) = exprs.iter().find_map(|x| x.as_literal_err()) {
55-
*e = MirScalarExpr::literal(Err(err.clone()), e.typ(column_types).scalar_type);
56-
return;
54+
// Fold the call to an operand's literal error only when the function is
55+
// strict in errors: any operand error must make the whole call error. The
56+
// non-strict variadics are excluded, because they can absorb an operand's
57+
// error at runtime: AND/OR via a dominating `false`/`true` operand
58+
// (`false AND <error>` is `false`), and ErrorIfNull because it evaluates its
59+
// message argument only when the first argument is null. Every other variadic
60+
// here evaluates all of its operands, so the fold is valid. (Coalesce, also
61+
// non-strict, bailed out above.)
62+
if !matches!(
63+
func,
64+
VariadicFunc::And(_) | VariadicFunc::Or(_) | VariadicFunc::ErrorIfNull(_)
65+
) {
66+
if let Some(err) = exprs.iter().find_map(|x| x.as_literal_err()) {
67+
*e = MirScalarExpr::literal(Err(err.clone()), e.typ(column_types).scalar_type);
68+
return;
69+
}
5770
}
5871

5972
// Per-function dispatch. Arms are mutually exclusive on discriminant; the

test/sqllogictest/error_semantics.slt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,16 @@ select coalesce(a, (select a/b from test)) from test;
9393

9494
query error Evaluation error: division by zero
9595
select *, case when a = 5 then (select a/b from test) else 7 end from test;
96+
97+
# Regression for #37049. AND/OR are non-strict: a `false`/`true` operand absorbs
98+
# an erroring operand at runtime (`false AND <error>` is `false`), so `reduce`
99+
# must not fold `x AND <error>` / `x OR <error>` to the error. Before the fix
100+
# these spuriously errored with division by zero (test.a = 1).
101+
query I
102+
select a from test where (a = 2) and (1/0 = 1);
103+
----
104+
105+
query I
106+
select a from test where (a = 1) or (1/0 = 1);
107+
----
108+
1

test/sqllogictest/temporal.slt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,3 +440,73 @@ FROM (SELECT CAST('62143-12-31' AS date) + '200000 YEARS' AS c2
440440
FROM (SELECT
441441
FROM (SELECT FROM t) AS subq_0) AS subq_1
442442
WHERE pg_catalog."current_timestamp"() = pg_catalog.pg_postmaster_start_time()) AS subq_2;
443+
444+
# Regression test for https://linear.app/materializeinc/issue/CLU-137
445+
#
446+
# A temporal predicate (`mz_now() < ...`) shared across both branches of a
447+
# disjunction of conjunctions must be factored out of the disjunction, so that
448+
# it becomes a standalone conjunct the renderer can extract as a temporal
449+
# bound. The factoring is done by `undistribute_and_or` during predicate
450+
# canonicalization. The error-propagation fix in #37049 had skipped
451+
# undistribution for any expression that could error, and `mz_now()` always
452+
# counts as could-error, so the temporal predicate stayed buried inside the OR
453+
# and the renderer panicked with "Unsupported temporal predicate".
454+
455+
# A constant collection (`VALUES`) is readable at any logical time, so the MV
456+
# can be queried `AS OF` a small timestamp, like the `valid` MV above.
457+
statement ok
458+
CREATE VIEW clu137 (active, confirmed, enabled, label, valid_until) AS VALUES
459+
(true, true, true, CAST(NULL AS text), 10),
460+
(true, true, true, '', 20),
461+
(true, true, true, 'x', 30),
462+
(false, true, true, NULL, 40);
463+
464+
statement ok
465+
CREATE MATERIALIZED VIEW clu137_mv AS
466+
SELECT valid_until FROM clu137
467+
WHERE (active AND confirmed AND enabled AND label IS NULL AND mz_now() < valid_until)
468+
OR (active AND confirmed AND enabled AND label = '' AND mz_now() < valid_until);
469+
470+
query I rowsort
471+
SELECT * FROM clu137_mv AS OF 5;
472+
----
473+
10
474+
20
475+
476+
query I rowsort
477+
SELECT * FROM clu137_mv AS OF 15;
478+
----
479+
20
480+
481+
query I rowsort
482+
SELECT * FROM clu137_mv AS OF 25;
483+
----
484+
485+
# a non-common operand that could error keeps the shared temporal predicate
486+
# buried in the OR (factoring it out would change error semantics), so
487+
# extraction rejects it at plan time. A clean error, not a crash.
488+
statement ok
489+
CREATE VIEW clu137_fallible (active, label, divisor, valid_until) AS VALUES
490+
(true, CAST(NULL AS text), 5, 10),
491+
(true, '', 5, 20);
492+
493+
# `(100 / divisor) > 0` (can error) sits in only the second disjunct.
494+
statement error Unsupported temporal predicate
495+
CREATE MATERIALIZED VIEW clu137_fallible_mv AS
496+
SELECT valid_until FROM clu137_fallible
497+
WHERE (active AND label IS NULL AND mz_now() < valid_until)
498+
OR (active AND label = '' AND mz_now() < valid_until AND (100 / divisor) > 0);
499+
500+
# But a fallible operand common to every disjunct factors out fine: the limit is
501+
# commonality, not fallibility.
502+
statement ok
503+
CREATE MATERIALIZED VIEW clu137_common_fallible_mv AS
504+
SELECT valid_until FROM clu137_fallible
505+
WHERE (active AND label IS NULL AND mz_now() < valid_until AND (100 / divisor) > 0)
506+
OR (active AND label = '' AND mz_now() < valid_until AND (100 / divisor) > 0);
507+
508+
query I rowsort
509+
SELECT * FROM clu137_common_fallible_mv AS OF 5;
510+
----
511+
10
512+
20

0 commit comments

Comments
 (0)