From 5ccc1663516a0daa0c56def2c537a1fe13be912e Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 21:55:39 +0200 Subject: [PATCH 1/7] refactor(routing): hold path handlers by pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- handlers.go | 6 +++--- server.go | 37 +++++++++++++------------------------ 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/handlers.go b/handlers.go index b13e023..77207ce 100644 --- a/handlers.go +++ b/handlers.go @@ -21,15 +21,15 @@ type pathHandler struct { Options HandlerFunc } -func newPathHandlerFromPathFragment(pathFragment string) (pathHandler, error) { +func newPathHandlerFromPathFragment(pathFragment string) (*pathHandler, error) { pathMatcher, err := routing.NewPathMatcherFromString(pathFragment) if err != nil { - return pathHandler{}, fmt.Errorf( + return nil, fmt.Errorf( "failed to create path matcher for pathFragment '%s'. Err: %w", pathFragment, err, ) } - return pathHandler{ + return &pathHandler{ PathFragment: pathFragment, PathMatcher: pathMatcher, Head: nil, diff --git a/server.go b/server.go index 81901d1..f782d73 100644 --- a/server.go +++ b/server.go @@ -29,7 +29,7 @@ type App struct { mux *http.ServeMux server http.Server currentFragment string - pathHandlers []pathHandler + pathHandlers []*pathHandler } // New creates a new Govalin App instance. @@ -337,9 +337,10 @@ func (server *App) Shutdown() error { } func (server *App) getOrCreatePathHandlerByPath(path string) *pathHandler { - if existingPathHandler, pathNotFoundErr := server.getPathHandlerByPath(path); pathNotFoundErr == nil { + if existingPathHandler := server.getPathHandlerByPath(path); existingPathHandler != nil { return existingPathHandler } + newHandler, pathHandlerErr := newPathHandlerFromPathFragment(path) if pathHandlerErr != nil { slog.Error(fmt.Sprintf("Failed to create handler for path '%s'. Err: %v", path, pathHandlerErr)) @@ -347,30 +348,22 @@ func (server *App) getOrCreatePathHandlerByPath(path string) *pathHandler { } server.pathHandlers = append(server.pathHandlers, newHandler) - handler, err := server.getPathHandlerByPath(path) - if err != nil { - slog.Error(fmt.Sprintf("Failed to retrieve newly created handler for path '%s'. Err %v", path, err)) - os.Exit(1) - } - return handler + + return newHandler } -func (server *App) getPathHandlerByPath(path string) (*pathHandler, error) { - for i := range server.pathHandlers { - if server.pathHandlers[i].PathFragment == path { - return &server.pathHandlers[i], nil +func (server *App) getPathHandlerByPath(path string) *pathHandler { + for _, pathHandler := range server.pathHandlers { + if pathHandler.PathFragment == path { + return pathHandler } } - return &pathHandler{}, fmt.Errorf( - "no pathHandler found for given path %s", path, - ) + return nil } func (server *App) matchBeforeHandlers(call *Call) bool { - for i := range server.pathHandlers { - pathHandler := &server.pathHandlers[i] - + for _, pathHandler := range server.pathHandlers { if call.bypassLifecycle { return false } @@ -401,9 +394,7 @@ func (server *App) matchHandlers(call *Call) { // callHandlerByMethod runs the first registered handler for the method whose // path matches the request, and reports whether the request was handled. func (server *App) callHandlerByMethod(call *Call, method string) bool { - for i := range server.pathHandlers { - pathHandler := &server.pathHandlers[i] - + for _, pathHandler := range server.pathHandlers { if call.bypassLifecycle { return true } @@ -421,9 +412,7 @@ func (server *App) callHandlerByMethod(call *Call, method string) bool { } func (server *App) matchAfterHandlers(call *Call) { - for i := range server.pathHandlers { - pathHandler := &server.pathHandlers[i] - + for _, pathHandler := range server.pathHandlers { if call.bypassLifecycle { return } From e6f9d63a8dade15faec7c78be84d9e6b2b483b01 Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:03:53 +0200 Subject: [PATCH 2/7] perf(routing): scan only the handlers that own a before or after function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- handlers.go | 1 + server.go | 38 ++++++++++++++++++++++++++++++++++---- server_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/handlers.go b/handlers.go index 77207ce..7d8c40f 100644 --- a/handlers.go +++ b/handlers.go @@ -8,6 +8,7 @@ import ( ) type pathHandler struct { + order int PathFragment string PathMatcher routing.PathMatcher Before BeforeFunc diff --git a/server.go b/server.go index f782d73..69f56b3 100644 --- a/server.go +++ b/server.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "slices" "time" "github.com/pkkummermo/govalin/internal/http/headers" @@ -30,6 +31,8 @@ type App struct { server http.Server currentFragment string pathHandlers []*pathHandler + beforeHandlers []*pathHandler + afterHandlers []*pathHandler } // New creates a new Govalin App instance. @@ -152,6 +155,12 @@ func (server *App) addMethod(method string, fullPath string, methodHandler Handl // short circuited. func (server *App) Before(path string, beforeFunc BeforeFunc) { fullPath := server.currentFragment + path + + if beforeFunc == nil { + slog.Error(fmt.Sprintf("Before on path %s is nil.", fullPath)) + os.Exit(1) + } + handler := server.getOrCreatePathHandlerByPath(fullPath) if handler.Before != nil { @@ -160,6 +169,7 @@ func (server *App) Before(path string, beforeFunc BeforeFunc) { } handler.Before = beforeFunc + server.beforeHandlers = insertInRegistrationOrder(server.beforeHandlers, handler) } // Add an after handler to given path @@ -168,6 +178,12 @@ func (server *App) Before(path string, beforeFunc BeforeFunc) { // the same request. func (server *App) After(path string, afterFunc AfterFunc) { fullPath := server.currentFragment + path + + if afterFunc == nil { + slog.Error(fmt.Sprintf("After on path %s is nil.", fullPath)) + os.Exit(1) + } + handler := server.getOrCreatePathHandlerByPath(fullPath) if handler.After != nil { @@ -176,6 +192,7 @@ func (server *App) After(path string, afterFunc AfterFunc) { } handler.After = afterFunc + server.afterHandlers = insertInRegistrationOrder(server.afterHandlers, handler) } // Add a GET handler @@ -347,11 +364,24 @@ func (server *App) getOrCreatePathHandlerByPath(path string) *pathHandler { os.Exit(1) } + newHandler.order = len(server.pathHandlers) server.pathHandlers = append(server.pathHandlers, newHandler) return newHandler } +// insertInRegistrationOrder places a handler in a lifecycle list at the position +// its path holds in the route table, which is not where it was appended: Before +// and After can be registered on a path some earlier route already created. +func insertInRegistrationOrder(handlers []*pathHandler, handler *pathHandler) []*pathHandler { + position := len(handlers) + for position > 0 && handlers[position-1].order > handler.order { + position-- + } + + return slices.Insert(handlers, position, handler) +} + func (server *App) getPathHandlerByPath(path string) *pathHandler { for _, pathHandler := range server.pathHandlers { if pathHandler.PathFragment == path { @@ -363,11 +393,11 @@ func (server *App) getPathHandlerByPath(path string) *pathHandler { } func (server *App) matchBeforeHandlers(call *Call) bool { - for _, pathHandler := range server.pathHandlers { + for _, pathHandler := range server.beforeHandlers { if call.bypassLifecycle { return false } - if pathHandler.Before != nil && pathHandler.PathMatcher.MatchesURL(call.URL().Path) { + if pathHandler.PathMatcher.MatchesURL(call.URL().Path) { call.pathParams = pathHandler.PathMatcher.PathParams(call.URL().Path) if !pathHandler.Before(call) { @@ -412,11 +442,11 @@ func (server *App) callHandlerByMethod(call *Call, method string) bool { } func (server *App) matchAfterHandlers(call *Call) { - for _, pathHandler := range server.pathHandlers { + for _, pathHandler := range server.afterHandlers { if call.bypassLifecycle { return } - if pathHandler.After != nil && pathHandler.PathMatcher.MatchesURL(call.URL().Path) { + if pathHandler.PathMatcher.MatchesURL(call.URL().Path) { call.pathParams = pathHandler.PathMatcher.PathParams(call.URL().Path) pathHandler.After(call) } diff --git a/server_test.go b/server_test.go index 51d974d..56dfa8f 100644 --- a/server_test.go +++ b/server_test.go @@ -344,6 +344,40 @@ func TestBefore(t *testing.T) { }) } +// TestLifecycleHandlersRunInRouteTableOrder pins which order overlapping before +// and after handlers run in when one of them is registered on a path an earlier +// route already put in the table: the table's order, not the order the Before +// and After calls were made in. +func TestLifecycleHandlersRunInRouteTableOrder(t *testing.T) { + app := newTestApp() + app.Get("/test", func(call *govalin.Call) { + call.Text(" handler") + }) + app.Before("/*", func(call *govalin.Call) bool { + call.Text(" wildcard-before") + return true + }) + app.Before("/test", func(call *govalin.Call) bool { + call.Text("test-before") + return true + }) + app.After("/*", func(call *govalin.Call) { + call.Text(" wildcard-after") + }) + app.After("/test", func(call *govalin.Call) { + call.Text(" test-after") + }) + + govalintest.Test(t, app, func(client *govalintest.Client) { + assert.Equal( + t, + "test-before wildcard-before handler test-after wildcard-after", + client.Get("/test"), + "Should run the handlers in the order their paths sit in the route table", + ) + }) +} + func TestAfter(t *testing.T) { app := newTestApp() app.Get("/test", func(call *govalin.Call) { From 4690cb43042fb552647110e7b71a988c8faa85f4 Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:07:11 +0200 Subject: [PATCH 3/7] fix(routing)!: reject a path segment that is neither a literal nor a parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/routing/path-parser_test.go | 22 ++++++++++++++ internal/routing/path-segment.go | 45 +++++++++++++--------------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/internal/routing/path-parser_test.go b/internal/routing/path-parser_test.go index 62f3337..a5e87bb 100644 --- a/internal/routing/path-parser_test.go +++ b/internal/routing/path-parser_test.go @@ -76,3 +76,25 @@ func TestNestedWildcardMatch(t *testing.T) { assert.Equal(t, false, pathMatcher.MatchesURL("/baz/baz/foo"), "Should not match on mismatched wildcard") assert.Equal(t, false, pathMatcher.MatchesURL("/foo/baz"), "Should not match on mismatched wildcard") } + +// A segment mixing literal text and a parameter used to silently compile to a +// pattern matching nothing its author meant; now it fails at registration. +func TestMalformedParameterSegmentIsRejected(t *testing.T) { + for _, path := range []string{"/a/{b}c", "/a/x{b}", "/a/{b}-{c}", "/a/{}", "/a/{{b}}", "/a/{b", "/a/b}"} { + _, err := routing.NewPathMatcherFromString(path) + + assert.Error(t, err, "'%s' is not a path the matcher can express", path) + } +} + +// TestParameterSegmentSpansTheWholePiece keeps the shapes either side of the +// rejection above matching what they always did. +func TestParameterSegmentSpansTheWholePiece(t *testing.T) { + pathMatcher, err := routing.NewPathMatcherFromString("/a/{b}") + + assert.Nil(t, err) + assert.Equal(t, true, pathMatcher.MatchesURL("/a/foo"), "Should match a value in the parameter position") + assert.Equal(t, map[string]string{"b": "foo"}, pathMatcher.PathParams("/a/foo"), "Should capture the value") + assert.Equal(t, false, pathMatcher.MatchesURL("/a/"), "Should not match an empty parameter") + assert.Equal(t, false, pathMatcher.MatchesURL("/a/foo/bar"), "Should not span a separator") +} diff --git a/internal/routing/path-segment.go b/internal/routing/path-segment.go index 79ba88c..1f8eb6c 100644 --- a/internal/routing/path-segment.go +++ b/internal/routing/path-segment.go @@ -7,59 +7,56 @@ import ( ) type pathSegment struct { - PathPiece string GroupedRegex string PathNames []string } -var ( +const ( delimiterStart = "{" delimiterEnd = "}" + delimiters = delimiterStart + delimiterEnd wildcard = "*" - - wildcardPathSegment = pathSegment{ - PathPiece: "*", - PathNames: []string{}, - GroupedRegex: ".+?", - } ) -func newPathSegment(pathPiece string) (pathSegment, error) { - delimiterStartCount := strings.Count(pathPiece, delimiterStart) - delimiterEndCount := strings.Count(pathPiece, delimiterEnd) - totalDelimiters := delimiterStartCount + delimiterEndCount - - if delimiterStartCount != delimiterEndCount { - return pathSegment{}, fmt.Errorf("number of '%d' and '%d' is not the same", delimiterStartCount, delimiterEndCount) - } +var wildcardPathSegment = pathSegment{ + PathNames: []string{}, + GroupedRegex: ".+?", +} +// newPathSegment reads one slash-separated piece of a route path. A piece is a +// wildcard, a literal, or a single '{name}' parameter that spans the whole +// piece; anything else is a path the matcher has no meaning for. +func newPathSegment(pathPiece string) (pathSegment, error) { if pathPiece == wildcard { return wildcardPathSegment, nil } - if totalDelimiters == 0 { + if !strings.ContainsAny(pathPiece, delimiters) { return createNormalPathSegment(pathPiece), nil } - if totalDelimiters == 2 && pathPiece[0:1] == delimiterStart && pathPiece[len(pathPiece)-1:] == delimiterEnd { - return createParameterPathSegment(pathPiece), nil + name, hasStart := strings.CutPrefix(pathPiece, delimiterStart) + name, hasEnd := strings.CutSuffix(name, delimiterEnd) + + if !hasStart || !hasEnd || name == "" || strings.ContainsAny(name, delimiters) { + return pathSegment{}, fmt.Errorf( + "path segment '%s' is neither a literal nor a '{name}' parameter", pathPiece, + ) } - return pathSegment{}, nil + return createParameterPathSegment(name), nil } func createNormalPathSegment(pathPiece string) pathSegment { return pathSegment{ - PathPiece: pathPiece, PathNames: []string{}, GroupedRegex: regexp.QuoteMeta(pathPiece), } } -func createParameterPathSegment(pathPiece string) pathSegment { +func createParameterPathSegment(name string) pathSegment { return pathSegment{ - PathPiece: pathPiece, - PathNames: []string{strings.Trim(strings.Trim(pathPiece, delimiterStart), delimiterEnd)}, + PathNames: []string{name}, GroupedRegex: "([^/]+?)", } } From cb79507ac6da6ccda05f3180e37a4d3e362c6476 Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:11:07 +0200 Subject: [PATCH 4/7] refactor(routing): match paths segment by segment instead of by regexp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/routing/path-parser.go | 142 +++++++++++++++++++-------- internal/routing/path-parser_test.go | 31 ++++++ internal/routing/path-segment.go | 41 +++----- 3 files changed, 149 insertions(+), 65 deletions(-) diff --git a/internal/routing/path-parser.go b/internal/routing/path-parser.go index c11f46a..308325b 100644 --- a/internal/routing/path-parser.go +++ b/internal/routing/path-parser.go @@ -3,15 +3,19 @@ package routing import ( "fmt" "log/slog" - "regexp" "strings" ) +// PathMatcher matches request URLs against one compiled route path. type PathMatcher struct { - pathParamNames []string - regexp *regexp.Regexp + segments []pathSegment + pathParamNames []string + optionalTrailingSlash bool + matchesEverything bool } +// NewPathMatcherFromString compiles a route path into a matcher. It fails on a +// path segment that is neither a literal, a '{name}' parameter nor a wildcard. func NewPathMatcherFromString(path string) (PathMatcher, error) { // A path of nothing but slashes is the root spelled oddly, and a route fragment // joined to a path produces it. The matcher has to match what the warning says it became. @@ -20,17 +24,11 @@ func NewPathMatcherFromString(path string) (PathMatcher, error) { slog.Warn(fmt.Sprintf("The path '%s' was converted to /", path)) } - return PathMatcher{ - pathParamNames: []string{}, - regexp: regexp.MustCompile("^/$"), - }, nil + return PathMatcher{}, nil } - if path == "*" { - return PathMatcher{ - pathParamNames: []string{}, - regexp: regexp.MustCompile(".*?"), - }, nil + if path == wildcard { + return PathMatcher{matchesEverything: true}, nil } pathSegments, err := getPathSegments(path) @@ -39,28 +37,16 @@ func NewPathMatcherFromString(path string) (PathMatcher, error) { } pathParamNames := []string{} - for _, ps := range pathSegments { - pathParamNames = append(pathParamNames, ps.PathNames...) - } - - groupRegexpParts := []string{} - - for _, ps := range pathSegments { - groupRegexpParts = append(groupRegexpParts, ps.GroupedRegex) - } - - // Appended after the join, not as a segment: a segment carries a mandatory slash in - // front of the optional one, leaving the pattern matching only the doubled form. - optionalTrailingSlash := "" - if strings.HasSuffix(path, "/") { - optionalTrailingSlash = "/?" + for _, segment := range pathSegments { + if segment.kind == parameterSegment { + pathParamNames = append(pathParamNames, segment.text) + } } - fullGroupedRegexpString := "^/" + strings.Join(groupRegexpParts, "/") + optionalTrailingSlash + "$" - return PathMatcher{ - pathParamNames: pathParamNames, - regexp: regexp.MustCompile(fullGroupedRegexpString), + segments: pathSegments, + pathParamNames: pathParamNames, + optionalTrailingSlash: strings.HasSuffix(path, "/"), }, nil } @@ -86,7 +72,9 @@ func getPathSegments(path string) ([]pathSegment, error) { // MatchesURL checks whether given string URL matches the path. func (path *PathMatcher) MatchesURL(url string) bool { - return path.regexp.MatchString(url) + _, matches := path.match(url, nil) + + return matches } // PathParams extracts the path parameters from given string url according @@ -100,17 +88,91 @@ func (path *PathMatcher) PathParams(url string) map[string]string { return nil } - pathparamMap := map[string]string{} - pathParams := path.regexp.FindStringSubmatch(url) + values, matches := path.match(url, make([]string, 0, len(path.pathParamNames))) + if !matches { + slog.Error(fmt.Sprintf("The URL '%s' does not match the path it was asked for path params on", url)) + return map[string]string{} + } + + pathParamMap := make(map[string]string, len(values)) + for i, name := range path.pathParamNames { + pathParamMap[name] = values[i] + } + + return pathParamMap +} + +// match walks the URL across the compiled segments, appending the values it +// captures to values. A nil values skips capture, so the routes a request does +// not match cost nothing to rule out. +func (path *PathMatcher) match(url string, values []string) ([]string, bool) { + if path.matchesEverything { + return values, true + } + + if len(url) == 0 || url[0] != '/' { + return values, false + } + + return path.matchSegments(path.segments, url[1:], values) +} + +// matchSegments matches segments against a URL positioned at the first +// character of the first segment. +func (path *PathMatcher) matchSegments(segments []pathSegment, url string, values []string) ([]string, bool) { + if len(segments) == 0 { + return values, url == "" || (path.optionalTrailingSlash && url == "/") + } + + head, rest := segments[0], segments[1:] + + switch head.kind { + case literalSegment: + if !strings.HasPrefix(url, head.text) { + return values, false + } + + return path.matchSeparator(rest, url[len(head.text):], values) + + case parameterSegment: + end := strings.IndexByte(url, '/') + if end < 0 { + end = len(url) + } + if end == 0 { + return values, false + } + if values != nil { + values = append(values, url[:end]) + } + + return path.matchSeparator(rest, url[end:], values) + } + + // A wildcard spans separators and takes as little as it can, so the shortest + // remainder that lets the segments after it match is the one that wins. + if len(rest) == 0 { + return values, url != "" + } + + for consumed := 1; consumed < len(url); consumed++ { + if captured, matches := path.matchSeparator(rest, url[consumed:], values); matches { + return captured, true + } + } + + return values, false +} - if len(pathParams) != len(path.pathParamNames)+1 { - slog.Error("The number of path params is not the same as configured path names") - return pathparamMap +// matchSeparator consumes the slash between two segments. +func (path *PathMatcher) matchSeparator(segments []pathSegment, url string, values []string) ([]string, bool) { + if len(segments) == 0 { + return path.matchSegments(segments, url, values) } - for i, v := range path.pathParamNames { - pathparamMap[v] = pathParams[i+1] + if len(url) == 0 || url[0] != '/' { + return values, false } - return pathparamMap + return path.matchSegments(segments, url[1:], values) } diff --git a/internal/routing/path-parser_test.go b/internal/routing/path-parser_test.go index a5e87bb..c636f35 100644 --- a/internal/routing/path-parser_test.go +++ b/internal/routing/path-parser_test.go @@ -98,3 +98,34 @@ func TestParameterSegmentSpansTheWholePiece(t *testing.T) { assert.Equal(t, false, pathMatcher.MatchesURL("/a/"), "Should not match an empty parameter") assert.Equal(t, false, pathMatcher.MatchesURL("/a/foo/bar"), "Should not span a separator") } + +// TestWildcardBacktracksToTheSegmentsAfterIt covers the case a segment walk has +// to get right and a left-to-right scan does not: a wildcard takes as little as +// it can, so how much of the URL it swallows is only settled by whether the +// segments after it still match. +func TestWildcardBacktracksToTheSegmentsAfterIt(t *testing.T) { + pathMatcher, err := routing.NewPathMatcherFromString("/a/*/b/{id}") + assert.Nil(t, err) + + assert.Equal(t, true, pathMatcher.MatchesURL("/a/x/b/1"), "Should match with the wildcard taking one piece") + assert.Equal(t, true, pathMatcher.MatchesURL("/a/x/y/z/b/1"), "Should match with the wildcard spanning separators") + assert.Equal(t, true, pathMatcher.MatchesURL("/a/b/b/1"), "Should pass over a piece the later literal also matches") + assert.Equal(t, false, pathMatcher.MatchesURL("/a/b/1"), "Should not match with nothing left for the wildcard") + assert.Equal(t, false, pathMatcher.MatchesURL("/a/x/b/1/2"), "Should not let the parameter span a separator") + + assert.Equal( + t, + map[string]string{"id": "1"}, + pathMatcher.PathParams("/a/x/y/b/1"), + "Should capture from the match the wildcard backtracked into", + ) +} + +// TestWildcardMatchesAnyCharacter pins the wildcard as any character: the +// regexp it replaced skipped a newline a parameter in the same position accepted. +func TestWildcardMatchesAnyCharacter(t *testing.T) { + pathMatcher, err := routing.NewPathMatcherFromString("/a/*") + + assert.Nil(t, err) + assert.Equal(t, true, pathMatcher.MatchesURL("/a/x\ny"), "Should match a path holding a newline") +} diff --git a/internal/routing/path-segment.go b/internal/routing/path-segment.go index 1f8eb6c..9fb443e 100644 --- a/internal/routing/path-segment.go +++ b/internal/routing/path-segment.go @@ -2,13 +2,23 @@ package routing import ( "fmt" - "regexp" "strings" ) +type segmentKind uint8 + +const ( + literalSegment segmentKind = iota + parameterSegment + wildcardSegment +) + +// pathSegment is one slash-separated piece of a compiled route path. type pathSegment struct { - GroupedRegex string - PathNames []string + kind segmentKind + // text is the literal a literal segment matches, or the name a parameter + // segment captures under. A wildcard segment has neither. + text string } const ( @@ -18,21 +28,16 @@ const ( wildcard = "*" ) -var wildcardPathSegment = pathSegment{ - PathNames: []string{}, - GroupedRegex: ".+?", -} - // newPathSegment reads one slash-separated piece of a route path. A piece is a // wildcard, a literal, or a single '{name}' parameter that spans the whole // piece; anything else is a path the matcher has no meaning for. func newPathSegment(pathPiece string) (pathSegment, error) { if pathPiece == wildcard { - return wildcardPathSegment, nil + return pathSegment{kind: wildcardSegment}, nil } if !strings.ContainsAny(pathPiece, delimiters) { - return createNormalPathSegment(pathPiece), nil + return pathSegment{kind: literalSegment, text: pathPiece}, nil } name, hasStart := strings.CutPrefix(pathPiece, delimiterStart) @@ -44,19 +49,5 @@ func newPathSegment(pathPiece string) (pathSegment, error) { ) } - return createParameterPathSegment(name), nil -} - -func createNormalPathSegment(pathPiece string) pathSegment { - return pathSegment{ - PathNames: []string{}, - GroupedRegex: regexp.QuoteMeta(pathPiece), - } -} - -func createParameterPathSegment(name string) pathSegment { - return pathSegment{ - PathNames: []string{name}, - GroupedRegex: "([^/]+?)", - } + return pathSegment{kind: parameterSegment, text: name}, nil } From 350621621210fdb0b107308530b7364a9447ef9e Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:25:27 +0200 Subject: [PATCH 5/7] perf(routing): index routes in a segment tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/routing/path-parser.go | 50 ++++++--- internal/routing/path-segment.go | 33 +++--- routeindex.go | 173 +++++++++++++++++++++++++++++++ routeindex_test.go | 97 +++++++++++++++++ server.go | 22 ++-- 5 files changed, 336 insertions(+), 39 deletions(-) create mode 100644 routeindex.go create mode 100644 routeindex_test.go diff --git a/internal/routing/path-parser.go b/internal/routing/path-parser.go index 308325b..76eb17d 100644 --- a/internal/routing/path-parser.go +++ b/internal/routing/path-parser.go @@ -8,7 +8,7 @@ import ( // PathMatcher matches request URLs against one compiled route path. type PathMatcher struct { - segments []pathSegment + segments []Segment pathParamNames []string optionalTrailingSlash bool matchesEverything bool @@ -38,8 +38,8 @@ func NewPathMatcherFromString(path string) (PathMatcher, error) { pathParamNames := []string{} for _, segment := range pathSegments { - if segment.kind == parameterSegment { - pathParamNames = append(pathParamNames, segment.text) + if segment.Kind == ParameterSegment { + pathParamNames = append(pathParamNames, segment.Text) } } @@ -50,8 +50,8 @@ func NewPathMatcherFromString(path string) (PathMatcher, error) { }, nil } -func getPathSegments(path string) ([]pathSegment, error) { - pathSegments := []pathSegment{} +func getPathSegments(path string) ([]Segment, error) { + pathSegments := []Segment{} pathParts := strings.Split(path, "/") for _, pathPiece := range pathParts { @@ -102,6 +102,32 @@ func (path *PathMatcher) PathParams(url string) map[string]string { return pathParamMap } +// Segments returns the pieces the path compiled to, and whether the path is +// nothing but those pieces. A caller that indexes routes by their segments can +// key on the ones it gets; a path holding a wildcard, or one that matches every +// URL, matches by a rule its segments do not carry and reports false. +func (path *PathMatcher) Segments() ([]Segment, bool) { + if path.matchesEverything { + return nil, false + } + + for _, segment := range path.segments { + if segment.Kind == WildcardSegment { + return nil, false + } + } + + return path.segments, true +} + +// OptionalTrailingSlash reports whether the path was registered with a trailing +// slash, and so matches a URL spelled either way. A caller that decided a match +// by the segments alone still has to ask: the slash is a rule about the end of +// the path rather than a piece of it. +func (path *PathMatcher) OptionalTrailingSlash() bool { + return path.optionalTrailingSlash +} + // match walks the URL across the compiled segments, appending the values it // captures to values. A nil values skips capture, so the routes a request does // not match cost nothing to rule out. @@ -119,22 +145,22 @@ func (path *PathMatcher) match(url string, values []string) ([]string, bool) { // matchSegments matches segments against a URL positioned at the first // character of the first segment. -func (path *PathMatcher) matchSegments(segments []pathSegment, url string, values []string) ([]string, bool) { +func (path *PathMatcher) matchSegments(segments []Segment, url string, values []string) ([]string, bool) { if len(segments) == 0 { return values, url == "" || (path.optionalTrailingSlash && url == "/") } head, rest := segments[0], segments[1:] - switch head.kind { - case literalSegment: - if !strings.HasPrefix(url, head.text) { + switch head.Kind { + case LiteralSegment: + if !strings.HasPrefix(url, head.Text) { return values, false } - return path.matchSeparator(rest, url[len(head.text):], values) + return path.matchSeparator(rest, url[len(head.Text):], values) - case parameterSegment: + case ParameterSegment: end := strings.IndexByte(url, '/') if end < 0 { end = len(url) @@ -165,7 +191,7 @@ func (path *PathMatcher) matchSegments(segments []pathSegment, url string, value } // matchSeparator consumes the slash between two segments. -func (path *PathMatcher) matchSeparator(segments []pathSegment, url string, values []string) ([]string, bool) { +func (path *PathMatcher) matchSeparator(segments []Segment, url string, values []string) ([]string, bool) { if len(segments) == 0 { return path.matchSegments(segments, url, values) } diff --git a/internal/routing/path-segment.go b/internal/routing/path-segment.go index 9fb443e..f11f2eb 100644 --- a/internal/routing/path-segment.go +++ b/internal/routing/path-segment.go @@ -5,20 +5,25 @@ import ( "strings" ) -type segmentKind uint8 +// SegmentKind is what one piece of a route path matches. +type SegmentKind uint8 const ( - literalSegment segmentKind = iota - parameterSegment - wildcardSegment + // LiteralSegment matches exactly its text. + LiteralSegment SegmentKind = iota + // ParameterSegment matches any whole piece and captures it. + ParameterSegment + // WildcardSegment matches one or more characters across separators. + WildcardSegment ) -// pathSegment is one slash-separated piece of a compiled route path. -type pathSegment struct { - kind segmentKind - // text is the literal a literal segment matches, or the name a parameter +// Segment is one slash-separated piece of a compiled route path. +type Segment struct { + // Kind says what the segment matches. + Kind SegmentKind + // Text is the literal a literal segment matches, or the name a parameter // segment captures under. A wildcard segment has neither. - text string + Text string } const ( @@ -31,23 +36,23 @@ const ( // newPathSegment reads one slash-separated piece of a route path. A piece is a // wildcard, a literal, or a single '{name}' parameter that spans the whole // piece; anything else is a path the matcher has no meaning for. -func newPathSegment(pathPiece string) (pathSegment, error) { +func newPathSegment(pathPiece string) (Segment, error) { if pathPiece == wildcard { - return pathSegment{kind: wildcardSegment}, nil + return Segment{Kind: WildcardSegment}, nil } if !strings.ContainsAny(pathPiece, delimiters) { - return pathSegment{kind: literalSegment, text: pathPiece}, nil + return Segment{Kind: LiteralSegment, Text: pathPiece}, nil } name, hasStart := strings.CutPrefix(pathPiece, delimiterStart) name, hasEnd := strings.CutSuffix(name, delimiterEnd) if !hasStart || !hasEnd || name == "" || strings.ContainsAny(name, delimiters) { - return pathSegment{}, fmt.Errorf( + return Segment{}, fmt.Errorf( "path segment '%s' is neither a literal nor a '{name}' parameter", pathPiece, ) } - return pathSegment{kind: parameterSegment, text: name}, nil + return Segment{Kind: ParameterSegment, Text: name}, nil } diff --git a/routeindex.go b/routeindex.go new file mode 100644 index 0000000..b880b21 --- /dev/null +++ b/routeindex.go @@ -0,0 +1,173 @@ +package govalin + +import ( + "math" + "strings" + + "github.com/pkkummermo/govalin/internal/routing" +) + +// routeIndex answers which route serves a URL without asking every route: a +// tree over path segments, walked by the URL's pieces. Registration order +// still decides which of several matching routes wins, and wildcard routes, +// which the tree cannot key on, stay in an ordered list — see ADR 0015. +type routeIndex struct { + root *routeNode + wildcards []*pathHandler +} + +type routeNode struct { + literals map[string]*routeNode + param *routeNode + routes []*pathHandler + minOrder int +} + +func (index *routeIndex) add(handler *pathHandler) { + segments, indexable := handler.PathMatcher.Segments() + if !indexable { + index.wildcards = append(index.wildcards, handler) + return + } + + if index.root == nil { + index.root = &routeNode{minOrder: handler.order} + } + + node := index.root + for _, segment := range segments { + node = node.child(segment, handler.order) + } + + node.routes = append(node.routes, handler) +} + +// child returns the node a segment leads to, creating it if this is the first +// route to take that step. Routes arrive in registration order, so the order +// that creates a node is the lowest any route below it can have. +func (node *routeNode) child(segment routing.Segment, order int) *routeNode { + if segment.Kind == routing.ParameterSegment { + if node.param == nil { + node.param = &routeNode{minOrder: order} + } + + return node.param + } + + if child, exists := node.literals[segment.Text]; exists { + return child + } + + child := &routeNode{minOrder: order} + if node.literals == nil { + node.literals = map[string]*routeNode{} + } + node.literals[segment.Text] = child + + return child +} + +// match returns the route that serves the URL for the method — the first +// registered of those that match — or nil when none does. +func (index *routeIndex) match(url string, method string) *pathHandler { + walk := routeWalk{method: method, bestOrder: math.MaxInt} + + if index.root != nil && len(url) > 0 && url[0] == '/' { + pieces := url[1:] + + // A route path holds its trailing slash as a rule about how the path ends + // rather than as a piece of its own, so the URL's is read off before the walk. + walk.trailingSlash = strings.HasSuffix(pieces, "/") + if walk.trailingSlash { + pieces = pieces[:len(pieces)-1] + } + + if pieces == "" { + walk.acceptAt(index.root) + } else { + walk.visit(index.root, pieces) + } + } + + for _, handler := range index.wildcards { + if handler.order >= walk.bestOrder { + break + } + + if handler.GetHandlerByMethod(method) != nil && handler.PathMatcher.MatchesURL(url) { + walk.best = handler + walk.bestOrder = handler.order + + break + } + } + + return walk.best +} + +type routeWalk struct { + method string + best *pathHandler + bestOrder int + trailingSlash bool +} + +// visit descends the node by the first of the URL pieces it has left. +func (walk *routeWalk) visit(node *routeNode, pieces string) { + if node.minOrder >= walk.bestOrder { + return + } + + piece, rest, more := strings.Cut(pieces, "/") + child := node.literals[piece] + + // A parameter takes any piece there is, but never the absence of one. + param := node.param + if piece == "" { + param = nil + } + + if !more { + if child != nil { + walk.acceptAt(child) + } + if param != nil { + walk.acceptAt(param) + } + + return + } + + if child != nil { + walk.visit(child, rest) + } + if param != nil { + walk.visit(param, rest) + } +} + +// acceptAt takes the best route ending at a node the walk reached, which means +// every segment of it matched. What the segments do not carry is how the path +// spells its end, so the trailing slash is settled here. +func (walk *routeWalk) acceptAt(node *routeNode) { + if node.minOrder >= walk.bestOrder { + return + } + + for _, handler := range node.routes { + if handler.order >= walk.bestOrder { + return + } + + if walk.trailingSlash && !handler.PathMatcher.OptionalTrailingSlash() { + continue + } + + if handler.GetHandlerByMethod(walk.method) != nil { + walk.best = handler + walk.bestOrder = handler.order + + return + } + } +} diff --git a/routeindex_test.go b/routeindex_test.go new file mode 100644 index 0000000..57f1d24 --- /dev/null +++ b/routeindex_test.go @@ -0,0 +1,97 @@ +package govalin + +import ( + "math/rand/v2" + "net/http" + "strings" + "testing" +) + +// linearMatch is the scan the index replaced: walk the route table in +// registration order and take the first route that matches and answers the +// method. +func linearMatch(handlers []*pathHandler, url string, method string) *pathHandler { + for _, handler := range handlers { + if handler.GetHandlerByMethod(method) != nil && handler.PathMatcher.MatchesURL(url) { + return handler + } + } + + return nil +} + +// TestRouteIndexAgreesWithAScanOfTheTable is the property the index has to +// hold: it returns the route the table walk would have, whatever the table. +// Random tables reach the overlaps a written-out case list does not — a literal +// registered behind a parameter, two routes on the same node differing only in +// a trailing slash, a wildcard cutting in ahead of a tree route. +func TestRouteIndexAgreesWithAScanOfTheTable(t *testing.T) { + pieces := []string{"a", "b", "ab", "{id}", "{name}", "*", "c"} + urlPieces := []string{"a", "b", "ab", "c", "x", "yy", ""} + methods := []string{http.MethodGet, http.MethodPost, http.MethodDelete} + + random := rand.New(rand.NewPCG(1, 2)) + + for range 20000 { + app := New(func(config *Config) { + config.EnableAccessLog(false) + config.EnableStartupLog(false) + }) + + registered := map[string]bool{} + for range random.IntN(8) + 1 { + path := "" + for range random.IntN(3) + 1 { + path += "/" + pieces[random.IntN(len(pieces))] + } + if random.IntN(4) == 0 { + path += "/" + } + + method := methods[random.IntN(len(methods))] + if registered[method+" "+path] { + continue + } + registered[method+" "+path] = true + + app.addMethod(method, path, func(_ *Call) {}) + } + + url := "" + for range random.IntN(4) + 1 { + url += "/" + urlPieces[random.IntN(len(urlPieces))] + } + if random.IntN(4) == 0 { + url += "/" + } + + for _, method := range methods { + want := linearMatch(app.pathHandlers, url, method) + got := app.routes.match(url, method) + + if want != got { + t.Fatalf( + "%s %s over table [%s]: table walk picked %v, index picked %v", + method, url, describe(app.pathHandlers), fragmentOf(want), fragmentOf(got), + ) + } + } + } +} + +func describe(handlers []*pathHandler) string { + fragments := make([]string, 0, len(handlers)) + for _, handler := range handlers { + fragments = append(fragments, handler.PathFragment) + } + + return strings.Join(fragments, " ") +} + +func fragmentOf(handler *pathHandler) string { + if handler == nil { + return "" + } + + return handler.PathFragment +} diff --git a/server.go b/server.go index 69f56b3..3d754ab 100644 --- a/server.go +++ b/server.go @@ -31,6 +31,7 @@ type App struct { server http.Server currentFragment string pathHandlers []*pathHandler + routes routeIndex beforeHandlers []*pathHandler afterHandlers []*pathHandler } @@ -366,6 +367,7 @@ func (server *App) getOrCreatePathHandlerByPath(path string) *pathHandler { newHandler.order = len(server.pathHandlers) server.pathHandlers = append(server.pathHandlers, newHandler) + server.routes.add(newHandler) return newHandler } @@ -424,21 +426,15 @@ func (server *App) matchHandlers(call *Call) { // callHandlerByMethod runs the first registered handler for the method whose // path matches the request, and reports whether the request was handled. func (server *App) callHandlerByMethod(call *Call, method string) bool { - for _, pathHandler := range server.pathHandlers { - if call.bypassLifecycle { - return true - } - - handler := pathHandler.GetHandlerByMethod(method) - if handler != nil && pathHandler.PathMatcher.MatchesURL(call.URL().Path) { - call.pathParams = pathHandler.PathMatcher.PathParams(call.URL().Path) - handler(call) - - return true - } + pathHandler := server.routes.match(call.URL().Path, method) + if pathHandler == nil { + return false } - return false + call.pathParams = pathHandler.PathMatcher.PathParams(call.URL().Path) + pathHandler.GetHandlerByMethod(method)(call) + + return true } func (server *App) matchAfterHandlers(call *Call) { From e70fdd99c43d3c0d0c59c8be6c5fe471527cf18f Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:26:44 +0200 Subject: [PATCH 6/7] docs(adr): record the segment tree route index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONTEXT.md | 7 +++ docs/adr/0015-segment-tree-route-index.md | 70 +++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 docs/adr/0015-segment-tree-route-index.md diff --git a/CONTEXT.md b/CONTEXT.md index 64ee7c6..f69f91e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -340,6 +340,12 @@ segment. It is legal anywhere in a pattern, not only in the final position, so a gap in its middle. _Avoid_: a wildcard confined to one segment, a catch-all valid only as the last segment +**Whole-piece parameter**: +A `{name}` route segment names the whole piece between two slashes, never a part of one. A segment +mixing literal text with a parameter — `{id}c`, `x{id}`, `{a}-{b}` — is not a pattern govalin can +express, and a route carrying one fails at registration rather than matching nothing. +_Avoid_: a parameter embedded in a segment, a segment holding two parameters, a partial-piece capture + **Lifecycle breadth**: Before and After handlers run for *every* route whose pattern matches the URL, while the request is served by exactly one. Matching for the lifecycle is a collection; matching for the handler is a @@ -455,3 +461,4 @@ _Avoid_: a before handler shadowed by an earlier match, lifecycle handlers resol - `Vary` was read as naming the headers a response carries; resolved: it names the request headers the response was selected from, and a response header there is a cache key of nothing. - Declaring a `Vary` was read as last-call-wins, the way a lifetime is; resolved: a lifetime is a policy one caller chooses for the response and a selecting header is a fact each layer knows a piece of, so declarations accumulate. - "Who may store this" was read as something a cache key could answer; resolved: a key selects between stored responses, and a response that must not be shared at all is a cache scope decision. +- A route index was read as requiring most-specific-wins, the way the Go routers built on this shape resolve overlaps; resolved: **Registration-order match** is the rule an index has to preserve, so the tree carries the lowest registration order beneath each node and explores every branch that could beat the best it has. diff --git a/docs/adr/0015-segment-tree-route-index.md b/docs/adr/0015-segment-tree-route-index.md new file mode 100644 index 0000000..a8be11a --- /dev/null +++ b/docs/adr/0015-segment-tree-route-index.md @@ -0,0 +1,70 @@ +# A segment tree that keeps registration order + +## Status + +accepted + +## Context and decision + +Matching a URL asked every registered route whether it matched. Each route held a compiled regexp, +so a 200 route table ran 200 regexp executions per request: 63% of the request's time, plus the +`sync.Pool` traffic of taking a regexp machine out and putting it back 200 times. What a route cost +depended on where its author had happened to register it — 331ns at the top of the table, 4611ns at +the bottom. + +Routes are now indexed by their segments. A literal segment is a key, a parameter segment 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 rather than the table. A match costs what the path is, not what the table holds. + +### Registration order is the constraint, not a detail to trade away + +Every other Go router of this shape resolves overlapping routes by specificity: a static segment +beats a parameter, a parameter beats a wildcard. Govalin's **Registration-order match** says the +opposite — the first route registered whose pattern matches is the one that serves, so a literal +registered after a parameter route that also matches is unreachable *by design*. That rule is the +author's tool for deciding which of two overlapping patterns wins, and an index that quietly replaced +it with specificity would change which handler answers an existing app's requests. + +So the tree preserves it. Each node carries the lowest registration order of any route beneath it, +and the walk carries the best order it has found; a subtree that cannot beat it is never entered. +Parameter and literal children are both explored, because either may hold the earlier route. + +### The tree indexes segments, so what is not a segment stays in a list + +A wildcard spans separators, which is not a shape a per-segment tree can key on. Rather than +complicate the tree with a construct that appears a handful of times in an app — a static mount, a +CORS `Before`, an HTTPS redirect — routes holding a wildcard stay in an ordered list scanned after +the walk, where the lowest order still wins. An app that registers many wildcard routes gets the +linear scan it always had, now over a segment matcher rather than a regexp. + +The same reasoning removed the regexps: the patterns the parser built were only ever literals, +`([^/]+?)` and `.+?` joined by slashes, which is a walk over slash-separated pieces expressed in a +language that cannot know that. `PathMatcher` walks its segments directly and captures parameters on +the same pass instead of running the pattern a second time for submatches. + +## Considered options + +- **A segment tree ordered by registration (chosen)** — see above. +- **A specificity tree, as httprouter and gin use** — simpler, no order bookkeeping, and faster still + because only one branch is ever explored. Rejected because it silently changes which handler serves + a URL in any app with overlapping routes. +- **Keeping the linear scan and only replacing the regexp** — this was measured: it took the bottom + of a 200 route table from 4350ns to 1950ns for a fraction of the code. Rejected as the endpoint + rather than the step it became, because the cost still scales with the table. +- **Making the tree the whole answer and confirming nothing** — the segments settle everything about + a match except how the path spells its end. Rather than re-run the full match to check, the matcher + reports whether its trailing slash is optional and the walk asks at the node. +- **A slice of literal children instead of a map** — measured 5ns faster per request on small tables + and 40ns slower on a node with 40 children. Rejected: the map is the one that does not reintroduce + a scan proportional to the route table, which is the thing being fixed. + +## Consequences + +- A route table's shape no longer changes what a request costs: 120ns at the top of a 200 route + table and 271ns at the bottom, against 331ns and 4611ns before, and a one route app is unchanged. +- Every route now exists twice, in `pathHandlers` and in the index. `getOrCreatePathHandlerByPath` is + the single place a route is created, so it is the single place the two can diverge. +- The index is only correct if it agrees with a scan of the table. That is a property rather than a + case list, so it is tested as one, against 20k generated route tables. +- Before and after handlers still scan, over lists holding only the routes that have one. They match + every route they hit rather than the first, so the index's answer is not the question they ask. From 087488aa7f196c1f3df1ae27ec6310b64524256c Mon Sep 17 00:00:00 2001 From: Per Kristian Kummermo Date: Sat, 22 Aug 2026 22:48:50 +0200 Subject: [PATCH 7/7] fix(server): name After in its duplicate-registration error --- server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.go b/server.go index 3d754ab..422e7c3 100644 --- a/server.go +++ b/server.go @@ -188,7 +188,7 @@ func (server *App) After(path string, afterFunc AfterFunc) { handler := server.getOrCreatePathHandlerByPath(fullPath) if handler.After != nil { - slog.Error(fmt.Sprintf("Before already exists on path %s.", fullPath)) + slog.Error(fmt.Sprintf("After already exists on path %s.", fullPath)) os.Exit(1) }