perf(routing): index routes in a segment tree - #110
Merged
Merged
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Before/After now invoke lifecycle handlers unconditionally, so registering a nil handler can panic at request time (and there’s also a stale “Before already exists” error message in After).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR replaces the linear “ask every route’s regexp” matching path with a segment-indexed route tree that preserves registration-order match, and updates the path matcher to operate directly on parsed path segments (including parameter capture) rather than regexps.
Changes:
- Introduces
routeIndex(segment tree + wildcard list) and switches handler lookup toroutes.match(...)for O(segments) matching while keeping registration-order semantics. - Reworks
internal/routing.PathMatcherto compile paths into segments and perform direct matching/capture (including rejecting malformed mixed literal/parameter segments at registration). - Adjusts lifecycle handler execution to maintain route-table order via ordered
beforeHandlers/afterHandlers, and adds property-based tests for index correctness.
File summaries
| File | Description |
|---|---|
| server.go | Switches handler selection to routeIndex, adds ordered lifecycle handler lists, and updates path handler storage to pointers. |
| server_test.go | Adds a test pinning lifecycle handler order to route-table order for overlapping handlers. |
| routeindex.go | Implements the segment-tree route index and registration-order-preserving walk. |
| routeindex_test.go | Property test ensuring routeIndex matches the prior linear scan behavior across randomized tables/URLs. |
| internal/routing/path-segment.go | Replaces regex-centric path segment representation with typed Segment / SegmentKind, rejecting malformed parameter segments. |
| internal/routing/path-parser.go | Replaces regexp matching with segment-walk matching and parameter capture; exposes segment/indexing helpers. |
| internal/routing/path-parser_test.go | Adds tests for malformed segment rejection and wildcard backtracking/newline behavior. |
| handlers.go | Adds a stable registration order and returns *pathHandler from the constructor. |
| docs/adr/0015-segment-tree-route-index.md | Documents the decision and constraints behind the segment tree that preserves registration order. |
| CONTEXT.md | Codifies “whole-piece parameter” and documents how registration-order is preserved in the index. |
Review details
- Files reviewed: 10/10 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A path handler was stored by value and handed out as a pointer into the slice, so the pointer only stayed valid until the next registration grew it. Nothing indexed the table, so nothing held one across a registration — but the route index does, and every side list built from it would dangle on the next append. Storing the handlers by pointer also lets getOrCreatePathHandlerByPath return the handler it just appended instead of appending and looking the same path up a second time, and drops the not-found error nobody read.
…tion Every request walked the whole route table three times: once for the before handlers, once for the method handler, once for the after handlers. Two of those walks nil-checked their way through the entire table to find handlers that, in most apps, do not exist at all — 200 routes with no lifecycle handler registered cost 208ns of nothing. App now keeps the two lifecycle lists it iterates. They are built in route table order rather than in call order, because Before and After can be registered on a path an earlier route already put in the table, and that path keeps the position it had. A nil Before or After used to be dead weight the request walk skipped; in a prebuilt list it would be invoked, so registration now rejects it the way it already rejects a duplicate. First match in a 200 route table: 331ns -> 123ns, now flat in table size.
…parameter
A segment mixing literal text with a parameter — '/a/{b}c', '/a/x{b}',
'/a/{b}-{c}' — fell through the parser's shape check and came back as a
zero pathSegment with no error. It contributed an empty piece to the
pattern, so '/a/{b}c' compiled to '^/a/$': it matched '/a/' and no URL
the author could have meant, and said nothing about it at startup.
These now fail at registration, alongside the unbalanced spellings that
already did. '{}' joins them: a parameter with no name captured its value
under the empty key.
The registration path already exits on a bad route, so an app carrying
one of these stops at startup with the segment named instead of serving
a route that never matched anything.
PathPiece went with it — nothing has read it.
Every registered route held a compiled regexp and every request ran it,
so matching one URL against a 200 route table meant 200 regexp
executions — 63% of the request, plus the sync.Pool churn of taking a
machine out and putting it back 200 times. The patterns the parser built
were only ever literals, '([^/]+?)' and '.+?' joined by slashes, which is
a walk over slash-separated pieces written in a language that cannot
know that.
PathMatcher now keeps the segments it parsed and walks them: a literal is
a prefix compare, a parameter takes the piece up to the next slash, and a
wildcard tries the shortest remainder that lets the segments after it
match. PathParams captures during the same walk rather than running the
pattern a second time to pull submatches out of it.
Verified against the old patterns over 200k generated path and URL pairs,
comparing both the match and the captured parameters.
One deliberate difference: a wildcard now means any character. It used to
compile to '.', which skips a newline, so '/a/*' rejected a path that a
'{name}' parameter in the same position accepted — an artifact of the
regexp, not a rule anyone chose.
Last match in a 200 route table: 4350ns -> 1950ns, 5 -> 4 allocations.
Matching a URL asked every registered route whether it matched, so a route table cost what it held rather than what the URL was: 200 routes took 331ns to answer at the top of the table and 4611ns at the bottom. Routes are now indexed by their segments — a literal is a key, a parameter is the one child that takes any piece, and a route sits at the node its last segment reaches. The walk descends the URL's pieces instead of the table, which turns a match into work proportional to the path rather than to the number of routes. Registration order still decides which of several matching routes wins, which is what separates this from the specificity trees other Go routers use. Each node carries the lowest order of any route beneath it, so a subtree that cannot beat the best match found so far is never entered. A wildcard spans separators and is not a shape the tree can key on, so routes holding one stay in an ordered list scanned after the walk, where the lowest order still wins. The segments settle everything about a match except how the path spells its end, so PathMatcher now says whether its trailing slash is optional and the walk asks at the node rather than running the whole match again. Checked against the table walk it replaces over 20k generated route tables: same route, same registration order, for every method and URL. 200 routes: 331ns -> 120ns at the top of the table, 4611ns -> 271ns at the bottom, and a one route app is unchanged at 114ns.
The tree preserving registration order instead of resolving overlaps by specificity is the decision a reader will wonder about, since every other Go router of this shape does the opposite — and the reason it does not is that specificity would change which handler serves a URL in any app with overlapping routes. CONTEXT.md gains the parameter-segment rule the registration failure now enforces, and the reading of a route index that had to be resolved.
pkkummermo
force-pushed
the
perf/routing-segment-tree
branch
from
August 22, 2026 20:49
6985103 to
087488a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Matching a URL asked every registered route whether it matched, each through its own compiled regexp. What a route cost depended on where its author happened to register it: in a 200 route table, 331ns at the top and 4611ns at the bottom, with the regexp scan accounting for 63% of the request.
Routes are now indexed by their segments, and matching descends the URL's pieces instead of the table.
Registration order is preserved. Every other Go router of this shape resolves overlaps by specificity — static beats parameter beats wildcard — and CONTEXT.md defines the opposite as deliberate: the first route registered whose pattern matches is the one that serves. Each node carries the lowest registration order beneath it, so the walk explores every branch that could beat the best it has and skips the ones that cannot. ADR 0015 records why.
A wildcard spans separators and is not a shape the tree can key on, so routes holding one stay in an ordered list scanned after the walk.
One breaking change
A path segment mixing literal text with a parameter —
/a/{b}c,/a/x{b},/a/{b}-{c},/a/{}— used to fall through the parser and compile to a pattern with the segment missing:/a/{b}cbecame^/a/$, matching/a/and no URL its author could have meant, with no error. These now fail at registration. An app carrying one stops at startup with the segment named instead of serving a route that never matched anything.Verification
Two properties, each tested as one rather than as a case list:
Full suite and
-racepass; allocation budgets unchanged.Where this leaves govalin
Same harness, against gin and
http.ServeMux:The remaining gap on the last row is
PathParams()returning amap[string]stringwhere gin returns a slice — an API shape, not a routing cost.🤖 Generated with Claude Code