Skip to content

Look for a 405 neighbour only on paths the method has no route on - #2920

Open
ericproulx wants to merge 1 commit into
masterfrom
perf/skip-covered-neighbours
Open

Look for a 405 neighbour only on paths the method has no route on#2920
ericproulx wants to merge 1 commit into
masterfrom
perf/skip-covered-neighbours

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

When the routes for a request's method all miss its path, #neighbours walks @union — one greedy route per path — for a route to answer 405 with. A 404 therefore walks two unions: its method's, then every path.

Most of that second walk cannot succeed. collect_route_config_per_pattern groups routes by pattern_regexp and hands each group's greedy route the pattern those routes share. So once the union for a method has missed a path, the greedy route of every path that method has a route on has missed it too.

compile! now works out, per method, the greedy routes of the paths it has no route on, and compiles those into a union of their own; after a miss, only that one is walked. In an API where every path answers GET, a GET leaves nothing to walk.

Behaviour is unchanged

  • The subset is built from the same members as @union, in the same order, so a path resolves to the same greedy route either way — same 405, same Allow header.
  • A method with no routes of its own covers no path and still walks all of @union. That is also what auto-OPTIONS does, unless OPTIONS routes are declared.
  • Methods that leave the same paths uncovered (PUT and DELETE on a member, say) share one union.
  • Once a route has matched and cascaded, the neighbour is never read, so it is no longer looked up.

A subset of @union numbers its groups differently from @union itself, so each neighbour union carries the group of each of its routes rather than reading regexp_capture_group.

Measured

Ruby 4.0.6 without YJIT, median of 5 interleaved rounds against an extract of master. The API is CRUD-shaped: GET and POST on each collection, GET, PUT and DELETE on each member, prefix + path version + format :json.

10 resources 100 resources 250 resources
GET 404 +74.6% +97.9% +93.5%
POST 404 +24.3% +40.4% +31.3%
POST 405 +5.8% +27.7% +21.3%
GET, middle route (control) +4.8% −2.0% −0.7%
OPTIONS (control) −0.8% +0.5% −4.7%

POST gains less because it has no route on the member paths, which therefore stay candidates. Boot, full API instance build: +5.1%, +8.5%, +5.8%, and +0.9% at 500 resources.

The resource names are varied on purpose. Routes that share one literal (resource1, resource2, …) let Onigmo reject a path lacking it by substring search without walking the union at all, which inflates every miss measurement and is not something a real API gets.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

When the routes for a request's method all miss its path, `#neighbours`
walks `@union` -- one greedy route per path -- for a route to answer 405
with. A 404 therefore walks two unions: its method's, then every path.

Most of that second walk cannot succeed. `collect_route_config_per_pattern`
groups routes by `pattern_regexp` and hands each group's greedy route the
pattern those routes share, so once the union for a method has missed a
path, the greedy route of every path that method has a route on has missed
it too. `compile!` now works out, per method, the greedy routes of the paths
it has no route on and compiles those into a union of their own; after a
miss, only that one is walked. In an API where every path answers GET, a GET
leaves nothing to walk.

The subset is built from the same members as `@union`, in the same order, so
a path resolves to the same greedy route either way and the 405 and its Allow
header are unchanged. A method with no routes of its own covers no path and
still walks all of `@union` -- which is also what auto-OPTIONS does, unless
OPTIONS routes are declared. Methods that leave the same paths uncovered, PUT
and DELETE on a member say, share one union. And once a route has matched and
cascaded the neighbour is never read, so it is no longer looked up.

A subset of `@union` numbers its groups differently from `@union` itself, so
each neighbour union carries the group of each of its routes rather than
reading `regexp_capture_group`.

Measured on Ruby 4.0.6 without YJIT, median of 5 interleaved rounds against an
extract of master, on a CRUD-shaped API: GET and POST on each collection, GET,
PUT and DELETE on each member, varied resource names, prefix + path version +
`format :json`. At 10 / 100 / 250 resources:

  GET 404    +74.6%  +97.9%  +93.5%
  POST 404   +24.3%  +40.4%  +31.3%
  POST 405    +5.8%  +27.7%  +21.3%

POST gains less because it has no route on the member paths, which therefore
stay candidates. A route hit from the middle of the union and OPTIONS, both
unchanged paths, stay within +/-5%. Boot, full API instance build: +5.1%,
+8.5%, +5.8%, and +0.9% at 500 resources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant