Skip to content

Commit 5da29e2

Browse files
committed
fix: stop silent wrong answers in distributed planner
COUNT(DISTINCT) and unknown aggs no longer merge as SUM_OF_COUNTS. They gather rows and aggregate locally, with DISTINCT honored in AggregateOperator. GROUP BY keeps HAVING and ORDER BY. WINDOW is distributed as gather-then-window so ORDER BY cannot drop it. Adds mock coverage plus a live 2-shard INSERT-then-point-SELECT gtest (skips without 13306/13307) and a sqlengine sharded INSERT check.
1 parent a5bc6ea commit 5da29e2

10 files changed

Lines changed: 372 additions & 24 deletions

File tree

include/sql_engine/distributed_planner.h

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -141,28 +141,38 @@ class DistributedPlanner {
141141
}
142142

143143
case PlanNodeType::PROJECT: {
144-
// Check for PROJECT -> [SORT ->] [FILTER ->] AGGREGATE pattern
145-
// For aggregate queries, we want to handle the whole thing in distribute_aggregate
144+
// PROJECT -> [SORT ->] [FILTER(HAVING) ->] AGGREGATE
145+
PlanNode* sort_node = nullptr;
146+
PlanNode* having_node = nullptr;
146147
PlanNode* agg_child = node->left;
147-
if (agg_child && agg_child->type == PlanNodeType::SORT)
148+
if (agg_child && agg_child->type == PlanNodeType::SORT) {
149+
sort_node = agg_child;
148150
agg_child = agg_child->left;
149-
if (agg_child && agg_child->type == PlanNodeType::FILTER)
151+
}
152+
if (agg_child && agg_child->type == PlanNodeType::FILTER) {
153+
having_node = agg_child;
150154
agg_child = agg_child->left;
155+
}
151156
if (agg_child && agg_child->type == PlanNodeType::AGGREGATE) {
152-
// Extract aggregate info from the PROJECT select list
153157
push_agg_exprs_from_project(node, agg_child);
154158
PlanNode* dist_agg = distribute_aggregate(agg_child);
155-
if (dist_agg && dist_agg->type == PlanNodeType::MERGE_AGGREGATE) {
156-
// Re-add FILTER (HAVING) if present
157-
if (node->left && node->left->type == PlanNodeType::FILTER) {
159+
if (dist_agg && (dist_agg->type == PlanNodeType::MERGE_AGGREGATE ||
160+
dist_agg->type == PlanNodeType::AGGREGATE)) {
161+
PlanNode* top = dist_agg;
162+
if (having_node) {
158163
PlanNode* having = make_plan_node(arena_, PlanNodeType::FILTER);
159-
having->filter.expr = node->left->filter.expr;
160-
having->left = dist_agg;
161-
return having;
164+
having->filter.expr = having_node->filter.expr;
165+
having->left = top;
166+
top = having;
167+
}
168+
if (sort_node) {
169+
PlanNode* sort = make_plan_node(arena_, PlanNodeType::SORT);
170+
sort->sort = sort_node->sort;
171+
sort->left = top;
172+
top = sort;
162173
}
163-
return dist_agg;
174+
return top;
164175
}
165-
// For unsharded, the remote already computes everything
166176
return dist_agg;
167177
}
168178

@@ -189,6 +199,13 @@ class DistributedPlanner {
189199
case PlanNodeType::JOIN:
190200
return distribute_join(node);
191201

202+
case PlanNodeType::WINDOW: {
203+
PlanNode* result = make_plan_node(arena_, PlanNodeType::WINDOW);
204+
result->window = node->window;
205+
result->left = distribute_node(node->left);
206+
return result;
207+
}
208+
192209
case PlanNodeType::SET_OP: {
193210
PlanNode* result = make_plan_node(arena_, PlanNodeType::SET_OP);
194211
result->set_op = node->set_op;
@@ -261,6 +278,24 @@ class DistributedPlanner {
261278
return ctx;
262279
}
263280

281+
static bool contains_type(const PlanNode* node, PlanNodeType type) {
282+
if (!node) return false;
283+
if (node->type == type) return true;
284+
if (contains_type(node->left, type)) return true;
285+
if (contains_type(node->right, type)) return true;
286+
if (node->type == PlanNodeType::MERGE_AGGREGATE) {
287+
for (uint16_t i = 0; i < node->merge_aggregate.child_count; ++i) {
288+
if (contains_type(node->merge_aggregate.children[i], type)) return true;
289+
}
290+
}
291+
if (node->type == PlanNodeType::MERGE_SORT) {
292+
for (uint16_t i = 0; i < node->merge_sort.child_count; ++i) {
293+
if (contains_type(node->merge_sort.children[i], type)) return true;
294+
}
295+
}
296+
return false;
297+
}
298+
264299
// Case 1 & 2: Distribute a scan (possibly with filter pushed down)
265300
PlanNode* distribute_scan(PlanNode* scan_node,
266301
const sql_parser::AstNode* where_expr,
@@ -548,10 +583,16 @@ class DistributedPlanner {
548583

549584
const TableInfo* table = ctx.scan->scan.table;
550585
if (!shards_.has_table(table->table_name) || !shards_.is_sharded(table->table_name)) {
551-
// Unsharded -- push the whole thing to remote
552586
return make_unsharded_aggregate(agg_node, ctx, table);
553587
}
554588

589+
if (!all_aggregates_two_phase(agg_node)) {
590+
PlanNode* result = make_plan_node(arena_, PlanNodeType::AGGREGATE);
591+
result->aggregate = agg_node->aggregate;
592+
result->left = distribute_node(agg_node->left);
593+
return result;
594+
}
595+
555596
// Sharded aggregate: each shard computes partial aggregates.
556597
// Build remote project expressions: group-by cols + partial agg expressions
557598
const auto& shard_list = shards_.get_shards(table->table_name);
@@ -661,6 +702,24 @@ class DistributedPlanner {
661702
return make_remote_scan_with_outputs(backend, sql, table, projs);
662703
}
663704

705+
static bool is_two_phase_aggregate(const sql_parser::AstNode* expr) {
706+
if (!expr || expr->type != sql_parser::NodeType::NODE_FUNCTION_CALL) return false;
707+
if (expr->flags & sql_parser::FLAG_FUNC_DISTINCT) return false;
708+
sql_parser::StringRef name = expr->value();
709+
return name.equals_ci("COUNT", 5) || name.equals_ci("SUM", 3) ||
710+
name.equals_ci("AVG", 3) || name.equals_ci("MIN", 3) ||
711+
name.equals_ci("MAX", 3);
712+
}
713+
714+
bool all_aggregates_two_phase(const PlanNode* agg_node) const {
715+
if (!agg_node) return false;
716+
if (agg_node->aggregate.agg_count == 0) return true;
717+
for (uint16_t i = 0; i < agg_node->aggregate.agg_count; ++i) {
718+
if (!is_two_phase_aggregate(agg_node->aggregate.agg_exprs[i])) return false;
719+
}
720+
return true;
721+
}
722+
664723
void decompose_aggregate(const sql_parser::AstNode* expr,
665724
std::vector<const sql_parser::AstNode*>& projs,
666725
std::vector<uint8_t>& merge_ops) {
@@ -673,11 +732,9 @@ class DistributedPlanner {
673732
sql_parser::StringRef name = expr->value();
674733

675734
if (name.equals_ci("COUNT", 5)) {
676-
// Remote: COUNT(*) or COUNT(col), Local: SUM of counts
677735
projs.push_back(expr);
678736
merge_ops.push_back(static_cast<uint8_t>(MergeOp::SUM_OF_COUNTS));
679737
} else if (name.equals_ci("SUM", 3)) {
680-
// Remote: SUM(col), Local: SUM of sums
681738
projs.push_back(expr);
682739
merge_ops.push_back(static_cast<uint8_t>(MergeOp::SUM_OF_SUMS));
683740
} else if (name.equals_ci("AVG", 3)) {
@@ -726,7 +783,16 @@ class DistributedPlanner {
726783

727784
// Case 4: Distributed sort + limit
728785
PlanNode* distribute_sort(PlanNode* sort_node) {
729-
// Check if the child is a scan (possibly through filter) on a sharded table
786+
if (contains_type(sort_node->left, PlanNodeType::WINDOW) ||
787+
contains_type(sort_node->left, PlanNodeType::DERIVED_SCAN) ||
788+
contains_type(sort_node->left, PlanNodeType::AGGREGATE) ||
789+
contains_type(sort_node->left, PlanNodeType::MERGE_AGGREGATE)) {
790+
PlanNode* result = make_plan_node(arena_, PlanNodeType::SORT);
791+
result->sort = sort_node->sort;
792+
result->left = distribute_node(sort_node->left);
793+
return result;
794+
}
795+
730796
ScanContext ctx = extract_scan_context(sort_node->left);
731797
if (!ctx.scan || !ctx.scan->scan.table) {
732798
PlanNode* result = make_plan_node(arena_, PlanNodeType::SORT);
@@ -795,6 +861,15 @@ class DistributedPlanner {
795861
// Check if child is Sort on sharded table
796862
if (limit_node->left && limit_node->left->type == PlanNodeType::SORT) {
797863
PlanNode* sort_node = limit_node->left;
864+
if (contains_type(sort_node->left, PlanNodeType::WINDOW) ||
865+
contains_type(sort_node->left, PlanNodeType::DERIVED_SCAN) ||
866+
contains_type(sort_node->left, PlanNodeType::AGGREGATE) ||
867+
contains_type(sort_node->left, PlanNodeType::MERGE_AGGREGATE)) {
868+
PlanNode* result = make_plan_node(arena_, PlanNodeType::LIMIT);
869+
result->limit = limit_node->limit;
870+
result->left = distribute_node(limit_node->left);
871+
return result;
872+
}
798873
ScanContext ctx = extract_scan_context(sort_node->left);
799874
if (ctx.scan && ctx.scan->scan.table) {
800875
const TableInfo* table = ctx.scan->scan.table;
@@ -839,7 +914,16 @@ class DistributedPlanner {
839914
}
840915
}
841916

842-
// Check if child is scan on sharded/unsharded table (limit without sort)
917+
if (contains_type(limit_node->left, PlanNodeType::WINDOW) ||
918+
contains_type(limit_node->left, PlanNodeType::DERIVED_SCAN) ||
919+
contains_type(limit_node->left, PlanNodeType::AGGREGATE) ||
920+
contains_type(limit_node->left, PlanNodeType::MERGE_AGGREGATE)) {
921+
PlanNode* result = make_plan_node(arena_, PlanNodeType::LIMIT);
922+
result->limit = limit_node->limit;
923+
result->left = distribute_node(limit_node->left);
924+
return result;
925+
}
926+
843927
ScanContext ctx = extract_scan_context(limit_node->left);
844928
if (ctx.scan && ctx.scan->scan.table) {
845929
const TableInfo* table = ctx.scan->scan.table;

include/sql_engine/operators/aggregate_op.h

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
#include <string>
1313
#include <cstring>
1414
#include <cmath>
15+
#include <unordered_set>
16+
#include "sql_parser/common.h"
1517

1618
namespace sql_engine {
1719

@@ -131,6 +133,8 @@ class AggregateOperator : public Operator {
131133
Value max_val{};
132134
bool has_value = false;
133135
bool count_star = false; // COUNT(*)
136+
bool distinct = false;
137+
std::unordered_set<std::string> seen;
134138
};
135139

136140
struct GroupState {
@@ -186,9 +190,9 @@ class AggregateOperator : public Operator {
186190

187191
if (expr->type == sql_parser::NodeType::NODE_FUNCTION_CALL) {
188192
sql_parser::StringRef name = expr->value();
193+
state.distinct = (expr->flags & sql_parser::FLAG_FUNC_DISTINCT) != 0;
189194
if (name.equals_ci("COUNT", 5)) {
190195
state.type = AggType::COUNT;
191-
// Check for COUNT(*)
192196
const sql_parser::AstNode* arg = expr->first_child;
193197
if (arg && arg->type == sql_parser::NodeType::NODE_ASTERISK) {
194198
state.count_star = true;
@@ -203,25 +207,32 @@ class AggregateOperator : public Operator {
203207
state.type = AggType::EXPR;
204208
}
205209

210+
static bool note_distinct(AggState& state, const sql_parser::AstNode* expr,
211+
const Value& v) {
212+
bool distinct = state.distinct ||
213+
(expr && (expr->flags & sql_parser::FLAG_FUNC_DISTINCT));
214+
if (!distinct) return true;
215+
return state.seen.insert(value_to_string(v)).second;
216+
}
217+
206218
void update_agg(AggState& state, const sql_parser::AstNode* expr,
207219
const std::function<Value(sql_parser::StringRef)>& resolver) {
208220
switch (state.type) {
209221
case AggType::COUNT: {
210222
if (state.count_star) {
211223
state.count++;
212224
} else {
213-
// COUNT(expr) - count non-null values
214225
const sql_parser::AstNode* arg = expr->first_child;
215226
Value v = evaluate_expression<D>(arg, resolver, functions_, arena_);
216-
if (!v.is_null()) state.count++;
227+
if (!v.is_null() && note_distinct(state, expr, v)) state.count++;
217228
}
218229
break;
219230
}
220231
case AggType::SUM:
221232
case AggType::AVG: {
222233
const sql_parser::AstNode* arg = expr->first_child;
223234
Value v = evaluate_expression<D>(arg, resolver, functions_, arena_);
224-
if (!v.is_null()) {
235+
if (!v.is_null() && note_distinct(state, expr, v)) {
225236
state.sum += v.to_double();
226237
state.count++;
227238
state.has_value = true;
@@ -231,7 +242,7 @@ class AggregateOperator : public Operator {
231242
case AggType::MIN: {
232243
const sql_parser::AstNode* arg = expr->first_child;
233244
Value v = evaluate_expression<D>(arg, resolver, functions_, arena_);
234-
if (!v.is_null()) {
245+
if (!v.is_null() && note_distinct(state, expr, v)) {
235246
if (!state.has_value || compare_values(v, state.min_val) < 0) {
236247
state.min_val = v;
237248
state.has_value = true;
@@ -242,7 +253,7 @@ class AggregateOperator : public Operator {
242253
case AggType::MAX: {
243254
const sql_parser::AstNode* arg = expr->first_child;
244255
Value v = evaluate_expression<D>(arg, resolver, functions_, arena_);
245-
if (!v.is_null()) {
256+
if (!v.is_null() && note_distinct(state, expr, v)) {
246257
if (!state.has_value || compare_values(v, state.max_val) > 0) {
247258
state.max_val = v;
248259
state.has_value = true;

include/sql_parser/common.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ static constexpr uint16_t FLAG_SET_OP_ALL = 0x01;
6666
// which matters for SHOW search_path / SHOW <var> canonical re-emission.
6767
static constexpr uint16_t FLAG_IDENT_DELIMITED = 0x01;
6868

69+
// -- Flags for NODE_FUNCTION_CALL --
70+
// Set when the call was written as FN(DISTINCT ...).
71+
static constexpr uint16_t FLAG_FUNC_DISTINCT = 0x01;
72+
6973
// -- Statement type (always set, even for PARTIAL/ERROR) --
7074

7175
enum class StmtType : uint8_t {

include/sql_parser/emitter.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,7 @@ class Emitter {
11161116
void emit_function_call(const AstNode* node) {
11171117
emit_value(node);
11181118
sb_.append_char('(');
1119+
if (node->flags & FLAG_FUNC_DISTINCT) sb_.append("DISTINCT ");
11191120
bool first = true;
11201121
for (const AstNode* arg = node->first_child; arg; arg = arg->next_sibling) {
11211122
if (!first) sb_.append(", ");

include/sql_parser/expression_parser.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,10 @@ class ExpressionParser {
331331
// argument list. Model it as a function call so consumers can
332332
// reject or handle the expression without leaving valid input
333333
// unconsumed.
334+
if (tok_.peek().type == TokenType::TK_DISTINCT) {
335+
func->flags |= FLAG_FUNC_DISTINCT;
336+
tok_.skip();
337+
}
334338
if (name_token.text.equals_ci("CAST", 4)) {
335339
AstNode* arg = parse();
336340
if (!arg || tok_.peek().type != TokenType::TK_AS) return func;

scripts/test_sqlengine.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ test_sharded() {
290290
# Total = 890000.
291291
out=$(run_sharded "SELECT SUM(salary) FROM users")
292292
assert_contains "sharded: SUM(salary) all users = 890000" "${out}" "890000"
293+
294+
# Engine INSERT then point-SELECT must agree (RANGE routing).
295+
out=$(run_sharded "INSERT INTO users (id, name, age, dept, salary) VALUES (11, 'Zed', 40, 'Test', 1)")
296+
assert_contains "sharded: INSERT id=11" "${out}" "Query OK, 1 row"
297+
out=$(run_sharded "SELECT name FROM users WHERE id = 11")
298+
assert_contains "sharded: point SELECT after INSERT (Zed)" "${out}" "Zed"
299+
run_sharded "DELETE FROM users WHERE id = 11" >/dev/null
293300
}
294301

295302
# ----------------------------------------------------------------------

0 commit comments

Comments
 (0)