Skip to content

Commit ecde031

Browse files
committed
fix: close P0 sharding holes from the #56 review
- Detect qualified SET t.id as a shard-key assignment and move the row. - Do not fall back to in-place UPDATE when a move SELECT finds no rows. - Empty IN (subquery) rewrites to FALSE instead of per-shard subquery. - Copy subquery string values into the arena (no ResultSet UAF). - Do not mutate the cached logical AGGREGATE when redistributing. - HASH routes integer-looking strings like ints. - Semi-join prune is INNER only; colocated joins emit LEFT/RIGHT/FULL. - Planner-time SELECTs go through TxnRoutingExecutor.
1 parent 5deab6f commit ecde031

7 files changed

Lines changed: 176 additions & 17 deletions

File tree

include/sql_engine/distributed_planner.h

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@ class DistributedPlanner {
159159
agg_child = agg_child->left;
160160
}
161161
if (agg_child && agg_child->type == PlanNodeType::AGGREGATE) {
162-
push_agg_exprs_from_project(node, agg_child);
163-
PlanNode* dist_agg = distribute_aggregate(agg_child);
162+
PlanNode* agg_copy = make_plan_node(arena_, PlanNodeType::AGGREGATE);
163+
agg_copy->aggregate = agg_child->aggregate;
164+
agg_copy->left = agg_child->left;
165+
push_agg_exprs_from_project(node, agg_copy);
166+
PlanNode* dist_agg = distribute_aggregate(agg_copy);
164167
if (dist_agg && (dist_agg->type == PlanNodeType::MERGE_AGGREGATE ||
165168
dist_agg->type == PlanNodeType::AGGREGATE)) {
166169
PlanNode* top = dist_agg;
@@ -1193,7 +1196,8 @@ class DistributedPlanner {
11931196
PlanNode* current = nullptr;
11941197
for (const auto& shard : shard_list) {
11951198
sql_parser::StringRef sql = qb_.build_select_join(
1196-
left_table, right_table, join_node->join.condition, where_expr);
1199+
left_table, right_table, join_node->join.condition, where_expr,
1200+
join_node->join.join_type);
11971201
PlanNode* rs = make_remote_scan(shard.backend_name.c_str(), sql, left_table);
11981202
if (!current) {
11991203
current = rs;
@@ -1310,6 +1314,7 @@ class DistributedPlanner {
13101314
const TableInfo* right_table) {
13111315
if (!join_node || !remote_executor_ || !join_node->join.condition)
13121316
return nullptr;
1317+
if (join_node->join.join_type != JOIN_INNER) return nullptr;
13131318
if (!left_table || !right_table) return nullptr;
13141319

13151320
bool ls = shards_.is_sharded(left_table->table_name);
@@ -1659,7 +1664,7 @@ class DistributedPlanner {
16591664
sql_parser::StringRef shard_key) const {
16601665
if (!set_columns || !shard_key.ptr) return false;
16611666
for (uint16_t i = 0; i < set_count; ++i) {
1662-
if (is_column_ref(set_columns[i], shard_key)) return true;
1667+
if (is_shard_key_ref(set_columns[i], shard_key)) return true;
16631668
}
16641669
return false;
16651670
}
@@ -1844,7 +1849,7 @@ class DistributedPlanner {
18441849
auto resolve = make_resolver(catalog_, table, src.values);
18451850
for (uint16_t i = 0; i < set_count; ++i) {
18461851
if (!set_cols[i]) continue;
1847-
const ColumnInfo* col = catalog_.get_column(table, set_cols[i]->value());
1852+
const ColumnInfo* col = catalog_.get_column(table, set_col_name(set_cols[i]));
18481853
if (!col) continue;
18491854
Value nv = value_null();
18501855
if (functions_) {
@@ -1950,9 +1955,7 @@ class DistributedPlanner {
19501955
}
19511956

19521957
if (moves.empty()) {
1953-
sql_parser::StringRef sql = qb_.build_update(
1954-
table, up.set_columns, up.set_exprs, up.set_count, where_expr);
1955-
return make_remote_scan(pruned[0].backend_name.c_str(), sql, table);
1958+
return make_noop_update(table, pruned[0].backend_name.c_str());
19561959
}
19571960

19581961
bool any_move = false;
@@ -1999,6 +2002,31 @@ class DistributedPlanner {
19992002
return current ? current : plan;
20002003
}
20012004

2005+
static sql_parser::StringRef set_col_name(const sql_parser::AstNode* node) {
2006+
if (!node) return sql_parser::StringRef{nullptr, 0};
2007+
if (node->type == sql_parser::NodeType::NODE_QUALIFIED_NAME) {
2008+
const sql_parser::AstNode* c = node->first_child;
2009+
if (c && c->next_sibling) return c->next_sibling->value();
2010+
}
2011+
return node->value();
2012+
}
2013+
2014+
PlanNode* make_noop_update(const TableInfo* table, const char* backend) {
2015+
sql_parser::StringBuilder sb(arena_, 64);
2016+
sb.append("UPDATE ");
2017+
if (table) sb.append(table->table_name.ptr, table->table_name.len);
2018+
sb.append(" SET ");
2019+
if (table && table->column_count > 0) {
2020+
sb.append(table->columns[0].name.ptr, table->columns[0].name.len);
2021+
sb.append(" = ");
2022+
sb.append(table->columns[0].name.ptr, table->columns[0].name.len);
2023+
} else {
2024+
sb.append("id = id");
2025+
}
2026+
sb.append(" WHERE 1 = 0");
2027+
return make_remote_scan(backend, sb.finish(), table);
2028+
}
2029+
20022030
bool is_column_ref(const sql_parser::AstNode* node, sql_parser::StringRef col_name) const {
20032031
if (!node) return false;
20042032
if (node->type == sql_parser::NodeType::NODE_COLUMN_REF ||
@@ -2175,7 +2203,7 @@ class DistributedPlanner {
21752203

21762204
for (const auto& row : rs.rows) {
21772205
if (row.column_count > 0) {
2178-
result.push_back(row.get(0));
2206+
result.push_back(copy_value_arena(row.get(0)));
21792207
}
21802208
}
21812209
return result;
@@ -2249,8 +2277,9 @@ class DistributedPlanner {
22492277
lit = sql_parser::make_node(arena_, sql_parser::NodeType::NODE_LITERAL_INT,
22502278
sql_parser::StringRef{s, static_cast<uint32_t>(n)});
22512279
} else if (v.tag == Value::TAG_STRING && v.str_val.ptr) {
2280+
Value owned = copy_value_arena(v);
22522281
lit = sql_parser::make_node(arena_, sql_parser::NodeType::NODE_LITERAL_STRING,
2253-
v.str_val);
2282+
owned.str_val);
22542283
} else if (v.tag == Value::TAG_DOUBLE) {
22552284
char buf[64];
22562285
int n = snprintf(buf, sizeof(buf), "%g", v.double_val);
@@ -2269,6 +2298,19 @@ class DistributedPlanner {
22692298
return new_in;
22702299
}
22712300

2301+
sql_parser::AstNode* make_false_pred() {
2302+
sql_parser::AstNode* eq = sql_parser::make_node(
2303+
arena_, sql_parser::NodeType::NODE_BINARY_OP,
2304+
sql_parser::StringRef{"=", 1});
2305+
eq->add_child(sql_parser::make_node(
2306+
arena_, sql_parser::NodeType::NODE_LITERAL_INT,
2307+
sql_parser::StringRef{"0", 1}));
2308+
eq->add_child(sql_parser::make_node(
2309+
arena_, sql_parser::NodeType::NODE_LITERAL_INT,
2310+
sql_parser::StringRef{"1", 1}));
2311+
return eq;
2312+
}
2313+
22722314
// Rewrite a WHERE expression by replacing the first IN (subquery) with IN (literals).
22732315
// Returns the rewritten expression, or the original if no rewrite needed.
22742316
const sql_parser::AstNode* rewrite_where_subquery(
@@ -2286,7 +2328,7 @@ class DistributedPlanner {
22862328
if (!values.empty()) {
22872329
return build_in_list_from_values(where_expr, values);
22882330
}
2289-
return where_expr;
2331+
return make_false_pred();
22902332
}
22912333
}
22922334
}

include/sql_engine/remote_query_builder.h

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,16 @@ class RemoteQueryBuilder {
9898
const TableInfo* left,
9999
const TableInfo* right,
100100
const sql_parser::AstNode* on_expr,
101-
const sql_parser::AstNode* where_expr)
101+
const sql_parser::AstNode* where_expr,
102+
uint8_t join_type = 0)
102103
{
103104
sql_parser::StringBuilder sb(arena_, 512);
104105
sb.append("SELECT * FROM ");
105106
if (left) sb.append(left->table_name.ptr, left->table_name.len);
106-
sb.append(" JOIN ");
107+
if (join_type == 1) sb.append(" LEFT JOIN ");
108+
else if (join_type == 2) sb.append(" RIGHT JOIN ");
109+
else if (join_type == 3) sb.append(" FULL JOIN ");
110+
else sb.append(" JOIN ");
107111
if (right) sb.append(right->table_name.ptr, right->table_name.len);
108112
if (on_expr) {
109113
sb.append(" ON ");

include/sql_engine/session.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,9 @@ class Session {
256256

257257
// If sharding is configured, distribute DML to remote backends.
258258
if (shard_map_ && remote_executor_) {
259+
routing_exec_.bind(remote_executor_, &txn_mgr_);
259260
DistributedPlanner<D> dp(*shard_map_, catalog_, parser_.arena(),
260-
remote_executor_, &functions_);
261+
&routing_exec_, &functions_);
261262
PlanNode* dist_plan = dp.distribute_dml(plan);
262263

263264
if (dp.last_error()) {
@@ -278,6 +279,7 @@ class Session {
278279
result.success = true;
279280
result.affected_rows = 0;
280281
for_each_remote_scan(dist_plan, [&](const PlanNode* rs) {
282+
if (!result.success) return;
281283
sql_parser::StringRef s{rs->remote_scan.remote_sql,
282284
rs->remote_scan.remote_sql_len};
283285
DmlResult shard_result;
@@ -377,8 +379,9 @@ class Session {
377379

378380
PlanNode* maybe_distribute(PlanNode* plan, sql_parser::Arena& arena) {
379381
if (!plan || !shard_map_ || !remote_executor_) return plan;
382+
routing_exec_.bind(remote_executor_, &txn_mgr_);
380383
DistributedPlanner<D> dplanner(*shard_map_, catalog_, arena,
381-
remote_executor_, &functions_);
384+
&routing_exec_, &functions_);
382385
PlanNode* dist = dplanner.distribute(plan);
383386
if (dplanner.last_error()) return nullptr;
384387
return dist;

include/sql_engine/shard_map.h

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <vector>
1010
#include <unordered_map>
1111
#include <algorithm>
12+
#include <cstdlib>
1213

1314
namespace sql_engine {
1415

@@ -154,9 +155,13 @@ class ShardMap {
154155
if (!cfg || cfg->shards.empty()) return false;
155156
size_t n = cfg->shards.size();
156157
switch (cfg->strategy) {
157-
case RoutingStrategy::HASH:
158+
case RoutingStrategy::HASH: {
159+
int64_t as_int = 0;
160+
if (parse_full_int(val, val_len, as_int))
161+
return try_shard_index_for_int(table_name, as_int, out);
158162
out = fnv1a_bytes(reinterpret_cast<const uint8_t*>(val), val_len) % n;
159163
return true;
164+
}
160165
case RoutingStrategy::RANGE:
161166
return false;
162167
case RoutingStrategy::LIST:
@@ -241,8 +246,12 @@ class ShardMap {
241246
if (!cfg || cfg->shards.empty()) return 0;
242247
size_t n = cfg->shards.size();
243248
switch (cfg->strategy) {
244-
case RoutingStrategy::HASH:
249+
case RoutingStrategy::HASH: {
250+
int64_t as_int = 0;
251+
if (parse_full_int(val, val_len, as_int))
252+
return shard_index_for_int(table_name, as_int);
245253
return fnv1a_bytes(reinterpret_cast<const uint8_t*>(val), val_len) % n;
254+
}
246255
case RoutingStrategy::RANGE:
247256
// RANGE is integer-keyed only. Fall back to scatter-friendly
248257
// shard 0 rather than producing a misleading single-shard
@@ -357,6 +366,18 @@ class ShardMap {
357366
return idx < n ? idx : (n == 0 ? 0 : n - 1);
358367
}
359368

369+
static bool parse_full_int(const char* val, uint32_t val_len, int64_t& out) {
370+
if (!val || val_len == 0 || val_len > 20) return false;
371+
char buf[24];
372+
std::memcpy(buf, val, val_len);
373+
buf[val_len] = '\0';
374+
char* end = nullptr;
375+
long long n = std::strtoll(buf, &end, 10);
376+
if (!end || end != buf + val_len) return false;
377+
out = static_cast<int64_t>(n);
378+
return true;
379+
}
380+
360381
static void split_keys(const std::string& spec, std::vector<std::string>& out) {
361382
size_t start = 0;
362383
while (start <= spec.size()) {

tests/test_distributed_dml.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,36 @@ TEST_F(DistributedDmlTest, UpdateShardKeyMovesRow) {
730730
"Carol");
731731
}
732732

733+
TEST_F(DistributedDmlTest, UpdateQualifiedShardKeyMovesRow) {
734+
execute_distributed_dml("INSERT INTO users (id, name, age) VALUES (3, 'Carol', 17)");
735+
const char* src = backend_for_id(3);
736+
const char* dst = backend_for_id(9);
737+
ASSERT_STRNE(src, dst);
738+
739+
auto result = execute_distributed_dml("UPDATE users SET users.id = 9 WHERE id = 3");
740+
EXPECT_TRUE(result.success) << result.error_message;
741+
EXPECT_EQ(row_count_on(src, "users"), 0u);
742+
EXPECT_EQ(row_count_on(dst, "users"), 1u);
743+
}
744+
745+
TEST_F(DistributedDmlTest, UpdateShardKeyNoMatchingRowIsNoop) {
746+
execute_distributed_dml("INSERT INTO users (id, name, age) VALUES (3, 'Carol', 17)");
747+
const char* home = backend_for_id(3);
748+
auto result = execute_distributed_dml("UPDATE users SET id = 9 WHERE id = 99");
749+
EXPECT_TRUE(result.success) << result.error_message;
750+
EXPECT_EQ(row_count_on(home, "users"), 1u);
751+
EXPECT_EQ(mock_executor.total_row_count("users"), 1u);
752+
}
753+
754+
TEST_F(DistributedDmlTest, InsertStringIntHashesLikeInt) {
755+
auto ins = execute_distributed_dml(
756+
"INSERT INTO users (id, name, age) VALUES ('3', 'Carol', 17)");
757+
EXPECT_TRUE(ins.success) << ins.error_message;
758+
EXPECT_EQ(row_count_on(backend_for_id(3), "users"), 1u);
759+
auto got = execute_distributed_select("SELECT name FROM users WHERE id = 3");
760+
ASSERT_EQ(got.row_count(), 1u);
761+
}
762+
733763
TEST_F(DistributedDmlTest, UpdateShardKeySameShard) {
734764
int64_t a = 3;
735765
int64_t b = a;

tests/test_distributed_planner.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,51 @@ TEST_F(DistributedPlannerTest, ColocatedJoinPushedToShards) {
12001200
}
12011201
}
12021202

1203+
TEST_F(DistributedPlannerTest, ColocatedLeftJoinEmitsLeftJoin) {
1204+
shard_map.add_table(TableShardConfig{
1205+
"orders", "user_id",
1206+
{ShardInfo{"shard_1"}, ShardInfo{"shard_2"}, ShardInfo{"shard_3"}}
1207+
});
1208+
1209+
Parser<Dialect::MySQL> parser;
1210+
const char* sql = "SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id";
1211+
auto pr = parser.parse(sql, std::strlen(sql));
1212+
ASSERT_EQ(pr.status, ParseResult::OK);
1213+
PlanBuilder<Dialect::MySQL> builder(catalog, parser.arena());
1214+
PlanNode* plan = builder.build(pr.ast);
1215+
DistributedPlanner<Dialect::MySQL> dp(shard_map, catalog, parser.arena());
1216+
PlanNode* dist = dp.distribute(plan);
1217+
ASSERT_NE(dist, nullptr);
1218+
1219+
std::vector<PlanNode*> remotes;
1220+
find_nodes(dist, PlanNodeType::REMOTE_SCAN, remotes);
1221+
ASSERT_FALSE(remotes.empty());
1222+
for (auto* rs : remotes) {
1223+
std::string remote(rs->remote_scan.remote_sql, rs->remote_scan.remote_sql_len);
1224+
EXPECT_NE(remote.find("LEFT JOIN"), std::string::npos) << remote;
1225+
}
1226+
}
1227+
1228+
TEST_F(DistributedPlannerTest, SemiJoinSkipsLeftJoin) {
1229+
Parser<Dialect::MySQL> parser;
1230+
const char* sql = "SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id";
1231+
auto pr = parser.parse(sql, std::strlen(sql));
1232+
ASSERT_EQ(pr.status, ParseResult::OK);
1233+
PlanBuilder<Dialect::MySQL> builder(catalog, parser.arena());
1234+
PlanNode* plan = builder.build(pr.ast);
1235+
DistributedPlanner<Dialect::MySQL> dp(shard_map, catalog, parser.arena(),
1236+
&mock_executor, &functions);
1237+
PlanNode* dist = dp.distribute(plan);
1238+
ASSERT_NE(dist, nullptr);
1239+
std::vector<PlanNode*> remotes;
1240+
find_nodes(dist, PlanNodeType::REMOTE_SCAN, remotes);
1241+
for (auto* rs : remotes) {
1242+
std::string remote(rs->remote_scan.remote_sql, rs->remote_scan.remote_sql_len);
1243+
if (remote.find("users") != std::string::npos)
1244+
EXPECT_EQ(remote.find(" IN "), std::string::npos) << remote;
1245+
}
1246+
}
1247+
12031248
TEST_F(DistributedPlannerTest, CompositeColocatedJoinPushedToShards) {
12041249
catalog.add_table("", "kv", {
12051250
{"tenant_id", SqlType::make_int(), false},

tests/test_shard_map.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
#include <set>
1212
#include <climits>
13+
#include <string>
1314

1415
using namespace sql_engine;
1516
using sql_parser::StringRef;
@@ -47,6 +48,19 @@ TEST(ShardMapHashTest, IsDeterministic) {
4748
}
4849
}
4950

51+
TEST(ShardMapHashTest, StringIntegerRoutesLikeInt) {
52+
ShardMap map;
53+
map.add_table(make_two_shards(RoutingStrategy::HASH));
54+
for (int i = -20; i < 20; ++i) {
55+
std::string s = std::to_string(i);
56+
size_t a = map.shard_index_for_int(sref("users"), i);
57+
size_t b = 99;
58+
ASSERT_TRUE(map.try_shard_index_for_string(
59+
sref("users"), s.c_str(), static_cast<uint32_t>(s.size()), b));
60+
EXPECT_EQ(a, b) << "for " << i;
61+
}
62+
}
63+
5064
TEST(ShardMapHashTest, DistributesAcrossBothShards) {
5165
// FNV-1a with modulo 2 should hit both shards for the small range
5266
// 0..63 — the test is loose because we don't want to over-specify

0 commit comments

Comments
 (0)