Skip to content

Commit 8817d84

Browse files
zzylolclaude
andcommitted
fix(planner): only a Filter over the aggregate is its HAVING
A Filter that reads an aggregate only from a scalar expression (PromQL scalar(), subqueries) stays a plain consumer instead of a HavingEntry; filters above are deduplicated by pointer. Print the InvalidAccuracy root once, add tests for Project/scalar consumers, mixed SQL HAVING, without/PerEntity and annotated non-root nodes, and rewrap the doc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7qG9aFyPij5uWsyAJCxDW
1 parent 0d0c2ab commit 8817d84

4 files changed

Lines changed: 215 additions & 31 deletions

File tree

‎crates/asap-aware-mapping/src/logical_candidates.rs‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,12 @@ pub enum LogicalCandidateError {
141141
AssignedPhysicalAnnotation,
142142
#[error("logical candidate input already contains ASAP operators")]
143143
ContainsAsap,
144-
/// `root` and `measure` locate the target; [`local_candidates_for_intent`]
145-
/// alone leaves them `None`.
144+
/// `root` (the root id's `Debug` form) and `measure` locate the target;
145+
/// [`local_candidates_for_intent`] alone leaves them `None`.
146146
#[error(
147-
"approximate accuracy of root {root:?}, measure {measure:?} requires epsilon and delta in (0, 1)"
147+
"approximate accuracy of root {}, measure {} requires epsilon and delta in (0, 1)",
148+
root.as_deref().unwrap_or("?"),
149+
measure.map_or_else(|| "?".to_string(), |measure| measure.to_string())
148150
)]
149151
InvalidAccuracy {
150152
root: Option<String>,
@@ -312,14 +314,20 @@ pub fn enumerate_local_logical_candidates<Id: std::fmt::Debug>(
312314
})
313315
.collect();
314316
let parents = &consumers[&Rc::as_ptr(&node)];
315-
let filters_above: Vec<(&Rc<OperatorNode>, &Predicate)> = parents
316-
.iter()
317-
.flatten()
318-
.filter_map(|parent| match parent.non_asap() {
319-
Some(NonASAPOp::Filter { pred, .. }) => Some((parent, pred)),
320-
_ => None,
321-
})
322-
.collect();
317+
// Only a `Filter` whose input is this aggregate is its `HAVING`; one
318+
// that reads it from a scalar expression stays a plain consumer.
319+
let mut filters_above: Vec<(&Rc<OperatorNode>, &Predicate)> = Vec::new();
320+
for parent in parents.iter().flatten() {
321+
if let Some(NonASAPOp::Filter { child, pred }) = parent.non_asap() {
322+
if Rc::ptr_eq(child, &node)
323+
&& !filters_above
324+
.iter()
325+
.any(|(filter, _)| Rc::ptr_eq(filter, parent))
326+
{
327+
filters_above.push((parent, pred));
328+
}
329+
}
330+
}
323331
for (filter, predicate) in &filters_above {
324332
let shared_by_all = filters_above.len() == parents.len()
325333
&& filters_above.iter().all(|(_, other)| other == predicate);

‎crates/asap-aware-mapping/tests/logical_candidates.rs‎

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,10 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
254254
.clone();
255255
producer.timing = Some(asap_types::post_asap::ExecutionTiming::QueryTime);
256256
assert!(matches!(
257-
enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(Rc::new(producer)))]),
257+
enumerate_local_logical_candidates(vec![(
258+
0,
259+
QueryRoot::Operator(Rc::new(producer.clone()))
260+
)]),
258261
Err(LogicalCandidateError::AssignedPhysicalAnnotation)
259262
));
260263
let mut stored = (*aggregate(AggIntent::Count {
@@ -264,9 +267,17 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
264267
stored.materialization =
265268
asap_types::post_asap::Materialization::MatAtQuery(asap_types::post_asap::Medium::Memory);
266269
assert!(matches!(
267-
enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(Rc::new(stored)))]),
270+
enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(Rc::new(stored.clone())))]),
268271
Err(LogicalCandidateError::AssignedPhysicalAnnotation)
269272
));
273+
// An annotated node below an unannotated root is rejected too.
274+
for annotated in [producer, stored] {
275+
let root = having_filter(Rc::new(annotated), always());
276+
assert!(matches!(
277+
enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(root))]),
278+
Err(LogicalCandidateError::AssignedPhysicalAnnotation)
279+
));
280+
}
270281
let summary = OperatorNode::new_shared(Operator::ASAP(ASAPOp::SummaryAgg {
271282
child: scan(false),
272283
family: FieldDataType::Sketch(
@@ -331,6 +342,10 @@ fn assigned_physical_annotation_asap_input_and_invalid_accuracy_are_rejected() {
331342
),
332343
"{error:?}"
333344
);
345+
assert!(
346+
error.to_string().contains("root \"q\", measure 1 "),
347+
"{error}"
348+
);
334349
}
335350

336351
/// Local TopK keeps both declared heap substrates without choosing an implementation.
@@ -674,3 +689,153 @@ fn example_1_exact_q2_prunes_topk_heaps() {
674689
&& choices.pruned.is_empty()));
675690
assert_eq!(found.len(), 2, "{found:#?}");
676691
}
692+
693+
/// The `HAVING` entries of the target whose measure satisfies `is`.
694+
fn having_of(
695+
targets: &[asap_aware_mapping::logical_candidates::LocalLogicalTarget],
696+
is: impl Fn(&AggIntent) -> bool,
697+
) -> Vec<HavingEntry> {
698+
let target = targets
699+
.iter()
700+
.find(|target| match target.target.non_asap() {
701+
Some(NonASAPOp::Aggregate { measures, .. }) => is(&measures[target.measure]),
702+
_ => false,
703+
})
704+
.expect("target");
705+
target.having.clone()
706+
}
707+
708+
/// `Column(column) > ScalarSubquery(subquery)`.
709+
fn above_scalar(column: usize, subquery: Rc<OperatorNode>) -> Predicate {
710+
Predicate(ScalarExpr::Compare {
711+
left: Box::new(ScalarExpr::Column(column)),
712+
op: CompareOpKind::Gt,
713+
right: Box::new(ScalarExpr::ScalarSubquery(subquery)),
714+
semantics: ExprSemantics::Sql,
715+
})
716+
}
717+
718+
/// A `Filter` that reads an aggregate only from its predicate is not that
719+
/// aggregate's `HAVING`; one that also filters the aggregate gives one entry.
720+
#[test]
721+
fn scalar_reference_filter_is_not_having() {
722+
let count = || {
723+
vec![AggIntent::Count {
724+
accuracy: approximate(),
725+
}]
726+
};
727+
let agg = aggregate_over(scan(false), count(), vec![], None);
728+
let root = having_filter(scan(false), above_scalar(1, agg.clone()));
729+
let targets = enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(root))])
730+
.unwrap()
731+
.targets;
732+
assert_eq!(targets.len(), 1);
733+
assert!(Rc::ptr_eq(&targets[0].target, &agg));
734+
assert!(targets[0].having.is_empty(), "{:?}", targets[0].having);
735+
736+
let agg = aggregate_over(scan(false), count(), vec![], None);
737+
let root = having_filter(agg.clone(), above_scalar(0, agg.clone()));
738+
let targets = enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(root.clone()))])
739+
.unwrap()
740+
.targets;
741+
assert_eq!(targets.len(), 1);
742+
assert_eq!(
743+
targets[0].having,
744+
vec![HavingEntry {
745+
filter: Some(root),
746+
predicate: above_scalar(0, agg),
747+
placement: HavingPlacement::AboveOutput,
748+
}]
749+
);
750+
}
751+
752+
/// PromQL `scalar(...)` operands are scalar references, not `HAVING`.
753+
#[test]
754+
fn promql_scalar_operand_is_not_having() {
755+
let roots = implicit_exact_promql("http_requests_total > scalar(sum(http_requests_total))");
756+
let targets = enumerate_local_logical_candidates(roots.into_iter().enumerate().collect())
757+
.unwrap()
758+
.targets;
759+
assert!(!targets.is_empty());
760+
for target in &targets {
761+
assert!(target.having.is_empty(), "{:?}", target.having);
762+
}
763+
764+
let roots = implicit_exact_promql("sum by (job) (x) > scalar(count(x))");
765+
let targets = enumerate_local_logical_candidates(roots.into_iter().enumerate().collect())
766+
.unwrap()
767+
.targets;
768+
assert!(having_of(&targets, |intent| matches!(intent, AggIntent::Count { .. })).is_empty());
769+
let sum = having_of(&targets, |intent| matches!(intent, AggIntent::Sum { .. }));
770+
assert_eq!(sum.len(), 1, "{sum:?}");
771+
assert_eq!(sum[0].placement, HavingPlacement::AboveOutput);
772+
}
773+
774+
/// A non-root, non-`Filter` consumer keeps a key-only `HAVING` above.
775+
#[test]
776+
fn having_shared_with_a_project_consumer_stays_above() {
777+
let node = grouped_count(None);
778+
let key = equals(0, ScalarValue::Utf8("a".into()));
779+
let filtered = having_filter(node.clone(), key);
780+
let project = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Project {
781+
cols: vec![asap_types::ir::ProjectItem {
782+
alias: None,
783+
expr: ScalarExpr::Column(0),
784+
}],
785+
qualifier: None,
786+
child: node,
787+
}))
788+
.unwrap();
789+
let targets = enumerate_local_logical_candidates(vec![
790+
(0, QueryRoot::Operator(filtered)),
791+
(1, QueryRoot::Operator(project)),
792+
])
793+
.unwrap()
794+
.targets;
795+
assert_eq!(targets.len(), 1);
796+
let placements: Vec<_> = targets[0]
797+
.having
798+
.iter()
799+
.map(|entry| entry.placement)
800+
.collect();
801+
assert_eq!(placements, vec![HavingPlacement::AboveOutput]);
802+
}
803+
804+
/// `without(...)` and per-entity aggregates have no fixed key prefix, so a
805+
/// `HAVING` over column 0 stays above.
806+
#[test]
807+
fn without_and_per_entity_having_stays_above() {
808+
for reduction in [
809+
Reduction::Reduce(asap_types::ir::operator_properties::GroupKeys::without(
810+
vec![1],
811+
)),
812+
Reduction::PerEntity,
813+
] {
814+
let node = OperatorNode::new_shared(Operator::NonASAP(NonASAPOp::Aggregate {
815+
child: scan(false),
816+
reduction: reduction.clone(),
817+
measures: vec![AggIntent::Count {
818+
accuracy: approximate(),
819+
}],
820+
output_names: vec![],
821+
filters: vec![],
822+
having: None,
823+
}))
824+
.unwrap();
825+
let root = having_filter(node, equals(0, ScalarValue::Utf8("a".into())));
826+
let targets = enumerate_local_logical_candidates(vec![(0, QueryRoot::Operator(root))])
827+
.unwrap()
828+
.targets;
829+
assert_eq!(targets.len(), 1, "{reduction:?}");
830+
let placements: Vec<_> = targets[0]
831+
.having
832+
.iter()
833+
.map(|entry| entry.placement)
834+
.collect();
835+
assert_eq!(
836+
placements,
837+
vec![HavingPlacement::AboveOutput],
838+
"{reduction:?}"
839+
);
840+
}
841+
}

‎crates/integration-tests/tests/logical_candidates_sql.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,11 @@ async fn global_constant_having_stays_above() {
5353
let targets = targets("SELECT count(*) FROM flows HAVING false").await;
5454
assert_eq!(placements(&targets), vec![HavingPlacement::AboveOutput]);
5555
}
56+
57+
#[tokio::test]
58+
async fn mixed_key_and_value_having_stays_above() {
59+
let targets =
60+
targets("SELECT src, count(*) FROM flows GROUP BY src HAVING src = 'a' AND count(*) > 10")
61+
.await;
62+
assert_eq!(placements(&targets), vec![HavingPlacement::AboveOutput]);
63+
}

‎docs/develop_docs/local-logical-candidates.md‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,24 +49,28 @@ the target keeps every candidate and records how the predicate is applied:
4949
- `HAVING` is recorded as `LocalLogicalTarget.having`, one `HavingEntry`
5050
(consumer `Filter`, predicate, placement) each. Only the adjacent form is
5151
recorded for push-down: the aggregate's own `having` (an entry with no
52-
`Filter`) and a `Filter` directly above the aggregate, which is how the SQL
53-
frontend lowers `HAVING`. A predicate that reads only `by(...)` group keys
54-
is `HavingPlacement::BelowAggregate`: it is pushed below the aggregate as a
55-
row predicate, like a measure filter. A consumer `Filter`'s predicate is
56-
pushed below only when every consumer of the aggregate applies the same
57-
predicate; otherwise another consumer would lose rows. Any other `HAVING`,
58-
and every `HAVING` of a global aggregate (no group keys, so its one output
59-
row exists even with no input rows), is `HavingPlacement::AboveOutput`: it
60-
stays a `Filter` above the finalize/estimate output, and approximate
61-
candidates of the target carry `guarantee = None`, since membership near
62-
the threshold may change. A value predicate anywhere above an approximate
63-
output, adjacent or not, gets `guarantee = None` through guarantee
64-
propagation (#575); multi-level push-down is tracked in #664.
52+
`Filter`) and a `Filter` whose input is the aggregate, which is how the SQL
53+
frontend lowers `HAVING`. A `Filter` that reads the aggregate only from a
54+
scalar expression (for example PromQL `scalar(...)`) is not its `HAVING`,
55+
though it still counts as a consumer. A predicate that reads only `by(...)`
56+
group keys is `HavingPlacement::BelowAggregate`: it is pushed below the
57+
aggregate as a row predicate, like a measure filter. A consumer `Filter`'s
58+
predicate is pushed below only when every consumer of the aggregate applies
59+
the same predicate; otherwise another consumer would lose rows. Any other
60+
`HAVING`, and every `HAVING` of a global aggregate (no group keys, so its
61+
one output row exists even with no input rows), is
62+
`HavingPlacement::AboveOutput`: it stays a `Filter` above the
63+
finalize/estimate output, and approximate candidates of the target carry
64+
`guarantee = None`, since membership near the threshold may change. A value
65+
predicate anywhere above an approximate output, adjacent or not, gets
66+
`guarantee = None` through guarantee propagation (#575); multi-level
67+
push-down is tracked in #664.
6568

6669
For example, an approximate single-column distinct count offers exact execution,
6770
HLL, Theta, KMV and UnivMon; an approximate `count` offers its exact `Count`
68-
accumulator, CMS, Count Sketch and UnivMon, but not plain `Exact`. These are candidate choices, not assessed accuracy
69-
certificates. Catalog order is stable and has no cost/preference meaning.
71+
accumulator, CMS, Count Sketch and UnivMon, but not plain `Exact`. These are
72+
candidate choices, not assessed accuracy certificates. Catalog order is stable
73+
and has no cost/preference meaning.
7074

7175
## Pruning
7276

@@ -97,9 +101,8 @@ sub-DAG, including applying measure filters and `HAVING` as recorded on the
97101
target, and validating its structure is still Pass 1 work, done later when
98102
candidates are realized (#575); Pass 2 then retains independent and shared
99103
alternatives before physical planning and complete workload selection. The
100-
descriptors are not
101-
executable plans, and callers must not execute the first choice as a selection
102-
policy.
104+
descriptors are not executable plans, and callers must not execute the first
105+
choice as a selection policy.
103106

104107
Opaque deployment extensions retain exact execution here; additional local
105108
alternatives require an explicit logical rule rather than a cost model making a

0 commit comments

Comments
 (0)