Skip to content

Commit b0d0f68

Browse files
committed
feat(planner): support GROUP BY aggregation over JOIN results
Queries that combine a JOIN with GROUP BY (e.g. counting or summing across joined collections) previously failed because the planner could not route aggregate nodes whose input was a join rather than a plain table scan. Add post-join aggregation support across the full execution path: - Extend HashJoin and BroadcastJoin plan variants with post_group_by and post_aggregates fields so the intent survives serialization to the Data Plane. - Teach convert_aggregate_over_join to detect a Join node beneath an Aggregate in the logical plan and produce a HashJoin plan carrying the aggregation metadata, instead of erroring on the missing table scan. - Add find_join_in_plan to recurse through Projection/Filter/ SubqueryAlias wrappers when looking for the underlying Join. - Add post_aggregate module with apply_post_aggregation, which runs GROUP BY and COUNT/SUM/AVG/MIN/MAX over the merged join rows in the Control Plane after all cores have responded. - Wire post-aggregation into the broadcast-join dispatch path so the aggregation step is applied when the plan carries non-empty post-aggregation fields.
1 parent f4a9753 commit b0d0f68

8 files changed

Lines changed: 257 additions & 8 deletions

File tree

nodedb/src/bridge/physical_plan/query.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub enum QueryOp {
3131
on: Vec<(String, String)>,
3232
join_type: String,
3333
limit: usize,
34+
/// Post-join GROUP BY columns (empty = no aggregation).
35+
post_group_by: Vec<String>,
36+
/// Post-join aggregates: (op, field) pairs (empty = no aggregation).
37+
post_aggregates: Vec<(String, String)>,
3438
},
3539

3640
/// Broadcast join: small side serialized in the plan.
@@ -41,6 +45,10 @@ pub enum QueryOp {
4145
on: Vec<(String, String)>,
4246
join_type: String,
4347
limit: usize,
48+
/// Post-join GROUP BY columns (empty = no aggregation).
49+
post_group_by: Vec<String>,
50+
/// Post-join aggregates: (op, field) pairs (empty = no aggregation).
51+
post_aggregates: Vec<(String, String)>,
4452
},
4553

4654
/// Shuffle join: repartition by join key via SPSC.

nodedb/src/control/planner/converter_helpers.rs

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,6 @@ impl PlanConverter {
174174
agg: &datafusion::logical_expr::Aggregate,
175175
tenant_id: TenantId,
176176
) -> crate::Result<Vec<PhysicalTask>> {
177-
let collection = extract_table_name(&agg.input).ok_or_else(|| crate::Error::PlanError {
178-
detail: "GROUP BY requires a table scan input".into(),
179-
})?;
180-
let vshard = VShardId::from_collection(&collection);
181-
182177
// Extract GROUP BY columns (filtering out time_bucket expressions
183178
// which are handled separately via bucket_interval_ms).
184179
let group_by: Vec<String> = agg
@@ -193,6 +188,17 @@ impl PlanConverter {
193188
})
194189
.collect();
195190

191+
// Check if the aggregate input is a JOIN — if so, produce a HashJoin
192+
// with post-aggregation fields instead of a standalone Aggregate plan.
193+
if let Some(join) = find_join_in_plan(&agg.input) {
194+
return self.convert_aggregate_over_join(join, &group_by, &agg.aggr_expr, tenant_id);
195+
}
196+
197+
let collection = extract_table_name(&agg.input).ok_or_else(|| crate::Error::PlanError {
198+
detail: "GROUP BY requires a table scan input".into(),
199+
})?;
200+
let vshard = VShardId::from_collection(&collection);
201+
196202
// Extract aggregate expressions: (op, field).
197203
let mut aggregates = Vec::new();
198204
for expr in &agg.aggr_expr {
@@ -287,6 +293,68 @@ impl PlanConverter {
287293
}),
288294
}])
289295
}
296+
297+
/// Convert an Aggregate over a Join into a HashJoin with post-aggregation.
298+
fn convert_aggregate_over_join(
299+
&self,
300+
join: &datafusion::logical_expr::Join,
301+
group_by: &[String],
302+
aggr_exprs: &[Expr],
303+
tenant_id: TenantId,
304+
) -> crate::Result<Vec<PhysicalTask>> {
305+
// Extract aggregate functions.
306+
let mut aggregates = Vec::new();
307+
for expr in aggr_exprs {
308+
if let Expr::AggregateFunction(func) = expr {
309+
let mut op = func.func.name().to_lowercase();
310+
let field = func
311+
.params
312+
.args
313+
.first()
314+
.map(|a| match a {
315+
Expr::Column(col) => col.name.clone(),
316+
Expr::Literal(..) => "*".into(),
317+
_ => format!("{a}"),
318+
})
319+
.unwrap_or_else(|| "*".into());
320+
if func.params.distinct {
321+
op = format!("{op}_distinct");
322+
}
323+
aggregates.push((op, field));
324+
}
325+
}
326+
327+
// Convert the join, then attach post-aggregation fields to the plan.
328+
let mut tasks = super::join::convert_join(join, tenant_id)?;
329+
for task in &mut tasks {
330+
match &mut task.plan {
331+
PhysicalPlan::Query(QueryOp::HashJoin {
332+
post_group_by,
333+
post_aggregates,
334+
..
335+
}) => {
336+
*post_group_by = group_by.to_vec();
337+
*post_aggregates = aggregates.clone();
338+
}
339+
_ => {}
340+
}
341+
}
342+
Ok(tasks)
343+
}
344+
}
345+
346+
/// Recursively find a `Join` node in the logical plan (through Projection/Filter).
347+
fn find_join_in_plan(
348+
plan: &datafusion::logical_expr::LogicalPlan,
349+
) -> Option<&datafusion::logical_expr::Join> {
350+
use datafusion::logical_expr::LogicalPlan;
351+
match plan {
352+
LogicalPlan::Join(join) => Some(join),
353+
LogicalPlan::Projection(proj) => find_join_in_plan(&proj.input),
354+
LogicalPlan::Filter(filter) => find_join_in_plan(&filter.input),
355+
LogicalPlan::SubqueryAlias(alias) => find_join_in_plan(&alias.input),
356+
_ => None,
357+
}
290358
}
291359

292360
/// Extract filters from an aggregate's input plan.

nodedb/src/control/planner/join.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ pub(super) fn convert_join(join: &Join, tenant_id: TenantId) -> crate::Result<Ve
109109
on: on_keys,
110110
join_type: join_type_str.to_string(),
111111
limit: 1000,
112+
post_group_by: Vec::new(),
113+
post_aggregates: Vec::new(),
112114
}),
113115
}]);
114116
}
@@ -125,6 +127,8 @@ pub(super) fn convert_join(join: &Join, tenant_id: TenantId) -> crate::Result<Ve
125127
on: on_keys,
126128
join_type: join_type_str.to_string(),
127129
limit: 1000,
130+
post_group_by: Vec::new(),
131+
post_aggregates: Vec::new(),
128132
}),
129133
}])
130134
}

nodedb/src/control/server/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod ilp_listener;
77
pub mod listener;
88
pub mod native;
99
pub mod pgwire;
10+
pub mod post_aggregate;
1011
pub mod resp;
1112
pub mod session;
1213
pub mod session_auth;

nodedb/src/control/server/pgwire/handler/dispatch.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ impl NodeDbPgHandler {
6565
ref on,
6666
ref join_type,
6767
limit,
68+
ref post_group_by,
69+
ref post_aggregates,
6870
},
6971
) = task.plan
7072
{
@@ -96,6 +98,10 @@ impl NodeDbPgHandler {
9698
let on_keys: Vec<(String, String)> =
9799
on.iter().map(|(l, r)| (l.clone(), r.clone())).collect();
98100

101+
let has_post_agg = !post_group_by.is_empty() || !post_aggregates.is_empty();
102+
let post_group_by = post_group_by.clone();
103+
let post_aggregates = post_aggregates.clone();
104+
99105
let broadcast_plan = crate::bridge::envelope::PhysicalPlan::Query(
100106
crate::bridge::physical_plan::QueryOp::BroadcastJoin {
101107
large_collection: left_collection.clone(),
@@ -104,15 +110,29 @@ impl NodeDbPgHandler {
104110
on: on_keys,
105111
join_type: join_type.clone(),
106112
limit,
113+
post_group_by: Vec::new(),
114+
post_aggregates: Vec::new(),
107115
},
108116
);
109-
return crate::control::server::dispatch_utils::broadcast_to_all_cores(
117+
let mut resp = crate::control::server::dispatch_utils::broadcast_to_all_cores(
110118
&self.state,
111119
task.tenant_id,
112120
broadcast_plan,
113121
0,
114122
)
115-
.await;
123+
.await?;
124+
125+
// Post-join aggregation: if the original query had GROUP BY on join
126+
// results, aggregate them now in the Control Plane.
127+
if has_post_agg {
128+
resp = crate::control::server::post_aggregate::apply_post_aggregation(
129+
resp,
130+
&post_group_by,
131+
&post_aggregates,
132+
)?;
133+
}
134+
135+
return Ok(resp);
116136
}
117137

118138
if let (Some(proposer), Some(tracker)) =
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//! Post-join aggregation in the Control Plane.
2+
//!
3+
//! When a query has `GROUP BY` over a `JOIN` result, the Data Plane cores
4+
//! return raw join rows. This module aggregates them in the Control Plane.
5+
6+
use std::collections::HashMap;
7+
8+
use crate::bridge::envelope::{Payload, Response};
9+
10+
/// Apply GROUP BY + aggregate functions on a join response payload.
11+
///
12+
/// The input response payload is a JSON array of objects (merged from all cores).
13+
/// Returns a new response with aggregated results.
14+
pub fn apply_post_aggregation(
15+
resp: Response,
16+
group_by: &[String],
17+
aggregates: &[(String, String)],
18+
) -> crate::Result<Response> {
19+
let payload_bytes = resp.payload.as_bytes();
20+
let json_text = std::str::from_utf8(payload_bytes).map_err(|e| crate::Error::PlanError {
21+
detail: format!("post-aggregation: invalid UTF-8: {e}"),
22+
})?;
23+
24+
let rows: Vec<serde_json::Value> =
25+
serde_json::from_str(json_text).map_err(|e| crate::Error::PlanError {
26+
detail: format!("post-aggregation: JSON parse error: {e}"),
27+
})?;
28+
29+
// Group rows by the GROUP BY columns.
30+
// Join results use "collection.field" keys, but GROUP BY may use bare field names.
31+
let mut groups: HashMap<Vec<String>, Vec<&serde_json::Value>> = HashMap::new();
32+
for row in &rows {
33+
let key: Vec<String> = group_by
34+
.iter()
35+
.map(|col| {
36+
resolve_field(row, col)
37+
.map(|v| match v {
38+
serde_json::Value::String(s) => s.clone(),
39+
other => other.to_string(),
40+
})
41+
.unwrap_or_default()
42+
})
43+
.collect();
44+
groups.entry(key).or_default().push(row);
45+
}
46+
47+
// Compute aggregates per group.
48+
let mut result = Vec::with_capacity(groups.len());
49+
for (key, group_rows) in &groups {
50+
let mut obj = serde_json::Map::new();
51+
52+
// Add GROUP BY columns.
53+
for (i, col) in group_by.iter().enumerate() {
54+
obj.insert(col.clone(), serde_json::Value::String(key[i].clone()));
55+
}
56+
57+
// Compute each aggregate.
58+
for (op, field) in aggregates {
59+
let agg_key = format!("{op}({field})");
60+
let value = compute_aggregate(op, field, group_rows);
61+
obj.insert(agg_key, value);
62+
}
63+
64+
result.push(serde_json::Value::Object(obj));
65+
}
66+
67+
let output = serde_json::to_vec(&result).map_err(|e| crate::Error::PlanError {
68+
detail: format!("post-aggregation: serialize error: {e}"),
69+
})?;
70+
71+
Ok(Response {
72+
payload: Payload::from_vec(output),
73+
..resp
74+
})
75+
}
76+
77+
/// Compute a single aggregate over a group of rows.
78+
fn compute_aggregate(op: &str, field: &str, rows: &[&serde_json::Value]) -> serde_json::Value {
79+
match op {
80+
"count" => serde_json::Value::Number(serde_json::Number::from(rows.len() as u64)),
81+
"sum" => {
82+
let sum: f64 = rows.iter().filter_map(|r| extract_number(r, field)).sum();
83+
serde_json::json!(sum)
84+
}
85+
"avg" => {
86+
let values: Vec<f64> = rows
87+
.iter()
88+
.filter_map(|r| extract_number(r, field))
89+
.collect();
90+
if values.is_empty() {
91+
serde_json::Value::Null
92+
} else {
93+
serde_json::json!(values.iter().sum::<f64>() / values.len() as f64)
94+
}
95+
}
96+
"min" => rows
97+
.iter()
98+
.filter_map(|r| extract_number(r, field))
99+
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
100+
.map(|v| serde_json::json!(v))
101+
.unwrap_or(serde_json::Value::Null),
102+
"max" => rows
103+
.iter()
104+
.filter_map(|r| extract_number(r, field))
105+
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
106+
.map(|v| serde_json::json!(v))
107+
.unwrap_or(serde_json::Value::Null),
108+
_ => serde_json::Value::Null,
109+
}
110+
}
111+
112+
/// Extract a numeric value from a JSON object field.
113+
fn extract_number(row: &serde_json::Value, field: &str) -> Option<f64> {
114+
if field == "*" {
115+
return Some(1.0);
116+
}
117+
resolve_field(row, field).and_then(|v| match v {
118+
serde_json::Value::Number(n) => n.as_f64(),
119+
serde_json::Value::String(s) => s.parse().ok(),
120+
_ => None,
121+
})
122+
}
123+
124+
/// Resolve a field name in a JSON row, handling "collection.field" prefixed keys.
125+
///
126+
/// Join results use keys like "users.name" but SQL refers to fields as just "name"
127+
/// or with alias "u.name". This tries exact match first, then suffix match.
128+
fn resolve_field<'a>(row: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
129+
// Exact match first.
130+
if let Some(v) = row.get(field) {
131+
return Some(v);
132+
}
133+
// Suffix match: look for any key ending with ".{field}".
134+
let suffix = format!(".{field}");
135+
if let serde_json::Value::Object(map) = row {
136+
for (k, v) in map {
137+
if k.ends_with(&suffix) {
138+
return Some(v);
139+
}
140+
}
141+
}
142+
None
143+
}

nodedb/src/data/executor/dispatch/other.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ impl CoreLoop {
4242
on,
4343
join_type,
4444
limit,
45+
..
4546
}) => self.execute_hash_join(
4647
task,
4748
tid,
@@ -150,6 +151,7 @@ impl CoreLoop {
150151
on,
151152
join_type,
152153
limit,
154+
..
153155
}) => self.execute_broadcast_join(
154156
task,
155157
tid,

nodedb/src/data/executor/handlers/document/sort.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,10 @@ pub(super) fn compare_docs_by_keys_binary(
169169
/// Each entry is `Option<(usize, usize)>` — byte range of the sort key value.
170170
type SortKeyOffsets = Vec<Option<(usize, usize)>>;
171171

172-
pub(in crate::data::executor) fn sort_rows(rows: &mut [(String, Vec<u8>)], sort_keys: &[(String, bool)]) {
172+
pub(in crate::data::executor) fn sort_rows(
173+
rows: &mut [(String, Vec<u8>)],
174+
sort_keys: &[(String, bool)],
175+
) {
173176
if sort_keys.is_empty() {
174177
return;
175178
}

0 commit comments

Comments
 (0)