Skip to content

perf(routing): index routes in a segment tree - #110

Merged
pkkummermo merged 7 commits into
mainfrom
perf/routing-segment-tree
Aug 23, 2026
Merged

pkkummermo merged 7 commits into
mainfrom
perf/routing-segment-tree

Conversation

@pkkummermo

Copy link
Copy Markdown
Owner

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.

200 routes before after
first registered 331ns · 1 alloc 120ns · 1
last registered 4611ns · 5 271ns · 4
one route app 113ns · 1 114ns · 1

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}c became ^/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:

  • the segment matcher against the regexp it replaces, over 200k generated path and URL pairs, comparing both the match and the captured parameters;
  • the index against the table scan it replaces, over 20k generated route tables, for every method and URL. Four deliberate mutations of the walk were each caught.

Full suite and -race pass; allocation budgets unchanged.

Where this leaves govalin

Same harness, against gin and http.ServeMux:

200 routes govalin gin ServeMux
first registered 187ns · 3 165ns · 3 82ns · 2
last registered 350ns · 6 185ns · 3 212ns · 4

The remaining gap on the last row is PathParams() returning a map[string]string where gin returns a slice — an API shape, not a routing cost.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 to routes.match(...) for O(segments) matching while keeping registration-order semantics.
  • Reworks internal/routing.PathMatcher to 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.

Comment thread server.go
Comment thread server.go
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
pkkummermo force-pushed the perf/routing-segment-tree branch from 6985103 to 087488a Compare August 22, 2026 20:49
@pkkummermo
pkkummermo merged commit 265193b into main Aug 23, 2026
11 of 12 checks passed
@pkkummermo
pkkummermo deleted the perf/routing-segment-tree branch August 23, 2026 18:02
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.

2 participants