fix(core): non-matching apply=1 query rule silently bypasses fast-routing (#5620)#5763
Conversation
…fast-routing `Query_Processor<QP_DERIVED>::process_query` (lib/Query_Processor.cpp) iterates the rule list and assigns `qr=*it` at the top of each iteration *before* the match check. When a rule does not match the loop `continue`s but `qr` is left pointing at that non-matching rule. After the loop, `__exit_process_mysql_query` reads `qr->apply` to decide whether to fall through to `mysql_query_rules_fast_routing`. If the last iterated (non-matching) rule happens to have `apply=1`, the leftover pointer makes the check skip fast-routing entirely — even though no rule actually matched the query. In production this is a silent fast-routing bypass: traffic that should follow the fast-routing destination ends up on the user's `default_hostgroup`. burnison reproduced it on 3.0.3 and 3.0.7 (issue #5620); his repro saw `ERROR 9001 Max connect timeout` because hg=0 was unroutable in that environment. Fix is a single-line reset: when the match check fails, set `qr = NULL` before `continue`. After the loop `qr` is then NULL iff no rule matched, which is exactly what the existing exit-block check `if (qr == NULL || qr->apply == false)` was always assuming. The fix lives in the templated implementation shared by MySQL and PgSQL query processors, so both protocols are covered. Adds reg_test_5620_fast_routing_qr_leak-t (registered in legacy-g6) which uses ProxySQL's built-in SQLite3 server as the backend so verification can read `stats_mysql_query_digest` directly without depending on backend reachability. Phase 1 confirms baseline fast-routing. Phase 2 inserts the trap rule (non-matching, apply=1) and re-runs the query; with the bug the query lands in `default_hostgroup`, with the fix it lands in the fast-routing hostgroup. Closes #5620
There was a problem hiding this comment.
Code Review
This pull request addresses issue #5620, where fast-routing rules were silently skipped if the final query rule in the evaluation chain had apply=1 but did not match the query. The fix ensures the query rule pointer is reset to NULL upon a non-match, preventing stale state from affecting subsequent routing logic. A new regression test and corresponding group configuration have been added to validate this behavior. I have no further feedback to provide.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughClears a stale current-rule pointer in query processing so fast-routing is not skipped when the last rule has ChangesQuery Routing Fast-routing Fix and Validation
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
burnison
left a comment
There was a problem hiding this comment.
I can confirm that this fixes the original bug reported. Thank you!
…fast-routing-qr-leak Signed-off-by: René Cannaò <rene@proxysql.com>
c18e194
into
integration/v3.0-batch-2026-05-13
|



Closes #5620.
Summary
Query_Processor<QP_DERIVED>::process_query(lib/Query_Processor.cpp) silently bypassesmysql_query_rules_fast_routingwhen nomysql_query_rulesrule matches the query but the last iterated rule in the chain hasapply=1. Reported by @burnison against 3.0.3 and 3.0.7.In production this manifests as traffic landing on the user's
default_hostgroupinstead of the fast-routing destination — silently when the default hostgroup is routable, or asERROR 9001 Max connect timeout reached while reaching hostgroup Nwhen it is not.Root cause
In the rules loop at
lib/Query_Processor.cpp:1775-1944:The loop assigns
qr = *itat the top of every iteration (1), before the match check. When the rule doesn't match, the bodycontinues (2) without resettingqr. After the loop ends without ever finding a match,qrpoints at the last iterated non-matching rule. The exit check at (3) reads that leftover pointer'sapplyfield; if it happens to be1, the fast-routing branch is skipped entirely.The exit-block check
if (qr == NULL || qr->apply == false)was written assumingqrrepresents the last applied rule (or NULL if none applied). The loop, however, treatsqras the last iterated rule. The fix realigns the loop with the check's assumption.The fix
One-line reset of
qrtoNULLwhen the match check fails:After the loop,
qris now NULL iff no rule matched, or points at the last matched rule that fell through withoutapply=1. Both cases exit-block check(qr == NULL || qr->apply == false)handles correctly.The change lives in the templated implementation
Query_Processor<QP_DERIVED>::process_query, which is instantiated for bothMySQL_Query_ProcessorandPgSQL_Query_Processor, so the fix covers both protocols. (Confirmed viagrep -n 'Query_Processor<' lib/MySQL_Query_Processor.cpp lib/PgSQL_Query_Processor.cpp.)Why a one-liner — and why not narrower
bool matched_and_applied) was considered; it adds a parallel state variable to a function that already hasqr,set_flagOUT,reiterate, and the goto labels. The exit-block predicate was already correct ifqrmeant what it was named — restoring that meaning is simpler than introducing another variable.qr = *itassignment after the match check would also work, but every line in the match body usesqrand would need to switch to a parameter or local. The reset beforecontinueis a single-line change with the smallest blast radius.Test
New regression test
test/tap/tests/reg_test_5620_fast_routing_qr_leak-t.cpp, registered inlegacy-g6(the same group asreg_test_2233_mirror_fast_routing-t, which uses the same SQLite3-server-as-backend pattern).The test uses ProxySQL's built-in SQLite3 server (port 6030) so both
default_hostgroupandfast_routing_hostgroupare reachable — that lets us verify which hostgroup the query actually went to viastats_mysql_query_digestrather than relying on connect failure as a proxy signal.mysql_query_rules. Issue a query, assert it landed in the fast-routing hostgroup. Catches setup mistakes before testing the bug condition.mysql_query_rulesrow withflagIN=99999, apply=1(matches the original repro). Reset digest stats. Issue the same query.default_hostgroup(fast-routing skipped). Assertion fails withexpected 100 got 0.fast_routing_hostgroup. Assertion passes.Both phases check
stats_mysql_query_digestfiltered by the test user, so the test is self-isolating.Files changed
lib/Query_Processor.cpp— the one-line reset + a 6-line comment explaining why.test/tap/tests/reg_test_5620_fast_routing_qr_leak-t.cpp— new regression test.test/tap/groups/groups.json— registers the test inlegacy-g6.Test plan
make build_tap_tests && WORKSPACE=$(pwd) INFRA_ID=dev-$USER TAP_GROUP=legacy-g6 test/infra/control/run-tests-isolated.bashpasses including the new test.lib/Query_Processor.cpp(keep the test) and confirm the new test fails at Phase 2 withgot 0 expected 100. This is the ground-truth regression check.legacy-g6once onv3.0HEAD to confirm no other test in the group regresses.apply=1#5620:mysql_query_ruleswithflagIN=99999, apply=1no longer breaks fast-routing for an unrelated user.Risk
Low. Single-line addition in a hot path, but the line only executes on the no-match
continuebranch (which previously did nothing withqreither) and the change is to clear stale state, not introduce new state. No callers, no signatures, no public API touched.Summary by CodeRabbit
Bug Fixes
Tests