Skip to content

fix: sort_route uses vars count as tiebreaker to match max vars#158

Open
janiussyafiq wants to merge 2 commits intoapi7:masterfrom
janiussyafiq:feat/match-max-vars
Open

fix: sort_route uses vars count as tiebreaker to match max vars#158
janiussyafiq wants to merge 2 commits intoapi7:masterfrom
janiussyafiq:feat/match-max-vars

Conversation

@janiussyafiq
Copy link
Copy Markdown

@janiussyafiq janiussyafiq commented Apr 3, 2026

Problem

When two routes share the same priority and uri, sort_route tiebreaks by path length. For identical paths (e.g. /*), this means insertion order wins — not specificity.

The result: a route with 1 vars condition is matched over a route with 2 vars conditions, even when the request satisfies both.

Ref: apache/apisix#9431
Fixes #157
Fixes apache/apisix#9431

Root Cause

sort_route in radixtree.lua:

local function sort_route(route_a, route_b)
if route_a.priority == route_b.priority then
return #route_a.path_org > #route_b.path_org
end
return (route_a.priority or 0) > (route_b.priority or 0)
end

When both priority and path_org length are equal, the comparison is a no-op and insert_tab_in_order preserves insertion order.

Fix

Add vars condition count as a third tiebreaker — more conditions means more specific, so it should be tried first. Since route_opts.vars is a compiled expression object by sort time, the count is stored separately as vars_len during route construction.
If the more-specific route's vars don't match at runtime, the matcher naturally falls through to the next candidate.

Summary by CodeRabbit

  • Bug Fixes

    • Route matching now consistently selects the more specific route when multiple routes share the same path and priority but differ in required parameters.
  • Tests

    • Added a test verifying correct selection of the more specific route when two routes match the same path but have different parameter requirements.

Copilot AI review requested due to automatic review settings April 3, 2026 07:27
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 00ee84c3-9e07-4113-bb91-69c4956efa3f

📥 Commits

Reviewing files that changed from the base of the PR and between 0ee4d2f and 67e3d5d.

📒 Files selected for processing (1)
  • lib/resty/radixtree.lua
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/resty/radixtree.lua

📝 Walkthrough

Walkthrough

Updated route sorting in the radix tree: when priority and path length tie, routes are now ordered by descending vars condition count (vars_len) so routes with more vars conditions are tried first. A new test verifies the behavior.

Changes

Cohort / File(s) Summary
Route Sorting Logic
lib/resty/radixtree.lua
Enhanced sort_route() to use vars_len as a tiebreaker when priority and #path_org are equal; common_route_data() now sets route_opts.vars_len = #route.vars`` when converting route.vars.
Route Matching Test
t/vars.t
Added TEST 20: registers two routes with identical path and priority but different vars counts and asserts the route with more vars conditions is matched.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: adding vars count as a tiebreaker in sort_route to prioritize routes with more specific conditions.
Linked Issues check ✅ Passed The code changes implement the required fix: adding vars_len as a tiebreaker in sort_route [#157, #9431] and storing vars count during route compilation to enable the comparison.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issues: modifications to sort_route logic and route compilation in radixtree.lua, plus a test case validating the fix.
E2e Test Quality Review ✅ Passed PR implements a proper E2E test validating that routes with more specific vars conditions are matched before less-specific ones when sharing identical URI and priority.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes route ordering in lua-resty-radixtree so that when multiple candidate routes have the same priority and equally-specific path, routes with more vars conditions are tried first (more specific match), preventing insertion order from incorrectly winning.

Changes:

  • Update sort_route to use vars condition count (vars_len) as an additional tiebreaker.
  • Persist vars_len on the constructed route_opts at route build time (since route_opts.vars is compiled by sort time).
  • Add a regression test covering selection of the route with the most vars conditions.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
lib/resty/radixtree.lua Adds vars_len tracking and uses it in sort_route to prefer more specific vars matches.
t/vars.t Adds a regression test ensuring the route with more vars conditions matches first.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 206 to 212
local function sort_route(route_a, route_b)
if route_a.priority == route_b.priority then
if #route_a.path_org == #route_b.path_org then
return (route_a.vars_len or 0) > (route_b.vars_len or 0)
end
return #route_a.path_org > #route_b.path_org
end
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tiebreaker is triggered when #route_a.path_org == #route_b.path_org, which is broader than the PR’s stated intent (“same priority and uri”). Different paths with the same length (possible in the same routes bucket for param/prefix matches) would now be reordered by vars_len, changing routing precedence beyond identical URIs. If the intent is only to break ties for identical paths, consider checking route_a.path_org == route_b.path_org (string equality) before comparing vars_len, and otherwise keep the existing ordering rules.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 434 to 445
if route.vars then
if type(route.vars) ~= "table" then
error("invalid argument vars", 2)
end

local route_expr, err = expr.new(route.vars)
if not route_expr then
error("failed to handle expression: " .. err, 2)
end
route_opts.vars = route_expr
route_opts.vars_len = #route.vars
end
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vars_len is set as #route.vars, but the vars DSL (lua-resty-expr) can include non-condition tokens (e.g., boolean operators like "and"/"or"), so #route.vars may not reflect the number of actual conditions. This can lead to incorrect “more-specific” ordering. Consider computing vars_len as the count of condition nodes (e.g., elements whose type is table) rather than the raw array length, and add a test case that includes boolean operators to lock in the intended behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation, but over-engineering. The concern is real: a route with vars = {{"AND", cond1, cond2}} gets vars_len = 1 while vars = {cond1, cond2} gets vars_len = 2, even though they express the same thing.
However:

  • In practice, the flat form is what all real users (including APISIX) use. The nested {"AND", ...} grouping syntax is rarely used.
  • Counting actual condition nodes would require recursive traversal and adds meaningful complexity for an edge case.
  • Even with the imprecision, the behavior is still correct — it doesn't break routing, it just has a slight inaccuracy in sort order for an unusual DSL form.
    ps: might need some input from maintainers

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
t/vars.t (1)

572-603: Add one fallback case to harden regression coverage.

This new test is great for the positive path. Consider adding a companion case where arg_k2 is missing/invalid, asserting fallback to metadata /aa (less-specific route).

📌 Suggested additional test block
+=== TEST 21: fallback to fewer vars when max-vars route fails
+--- config
+    location /t {
+        content_by_lua_block {
+            local radix = require("resty.radixtree")
+            local rx = radix.new({
+                {
+                    paths = {"/aa"},
+                    metadata = "metadata /aa",
+                    vars = {
+                        {"arg_k", "==", "v"},
+                    },
+                },
+                {
+                    paths = {"/aa"},
+                    metadata = "metadata /aa2",
+                    vars = {
+                        {"arg_k", "==", "v"},
+                        {"arg_k2", "==", "v2"},
+                    },
+                },
+            })
+
+            ngx.say(rx:match("/aa", {vars = ngx.var}))
+        }
+    }
+--- request
+GET /t?k=v
+--- no_error_log
+[error]
+--- response_body
+metadata /aa
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@t/vars.t` around lines 572 - 603, Add a companion test in t/vars.t that
exercises the fallback path when the second var condition fails: create a test
similar to "TEST 20: match max vars condition" but send a request missing or
with an invalid arg_k2 (e.g., GET /t?k=v without k2 or with k2=bad) and assert
the response_body is "metadata /aa" (the less-specific route) to ensure rx:match
falls back from the "metadata /aa2" case to the "metadata /aa" case.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@t/vars.t`:
- Around line 572-603: Add a companion test in t/vars.t that exercises the
fallback path when the second var condition fails: create a test similar to
"TEST 20: match max vars condition" but send a request missing or with an
invalid arg_k2 (e.g., GET /t?k=v without k2 or with k2=bad) and assert the
response_body is "metadata /aa" (the less-specific route) to ensure rx:match
falls back from the "metadata /aa2" case to the "metadata /aa" case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2277961a-8c93-4ea0-9760-f7e74c99ed50

📥 Commits

Reviewing files that changed from the base of the PR and between 8166eea and 0ee4d2f.

📒 Files selected for processing (2)
  • lib/resty/radixtree.lua
  • t/vars.t

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants