Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions docs/advanced-guide/routing-performance/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,11 @@ Two further caveats worth setting expectations against:
- **Matching is a minority of a request.** The middleware chain — tracing, logging, metrics, CORS —
dominates. So end-to-end throughput moves by less than the table above, approaching it only as the
route count grows.
- **The trie allocates slightly more.** Two extra allocations per matched request, for restoring the
path params and route template. This is a CPU and scaling win, not an allocation win.
- **The trie also allocates less**, where it used to allocate more. On a 100-route table, per
matched request: a static route costs 536 B / 8 allocations against the default matcher's
960 B / 12, and a parameterised route 1208 B / 11 against 1264 B / 13. The middleware chain is
composed once per route instead of per request, and an empty path-parameter map is no longer
stored. This applies to routes registered through the framework (`app.GET`, `app.POST`, ...).

## What stays the same

Expand All @@ -56,7 +59,18 @@ ordering and path cleaning all behave exactly as they do by default. Anything th
— `PathPrefix` routes, static file handlers, slash-spanning parameters like `{path:.*}` — is handled
by `mux` directly. Requests that match nothing are handed to `mux` in full.

Path parameters are unaffected: `ctx.PathParam("id")` and `mux.Vars(r)` work identically.
Path parameters are unaffected: `ctx.PathParam("id")` returns the same values under either
matcher. `mux.Vars(r)` returns the same values too, with one difference worth knowing: on a route
that declares no path parameters the trie leaves it `nil` where `mux` returns an empty non-nil map.
Every read behaves the same -- indexing gives the zero value, `len` is 0, and ranging does nothing --
but an explicit `mux.Vars(r) != nil` check answers differently.

Middleware registration becomes order-sensitive. Each route's middleware chain is composed once,
on its first request, and reused, so a middleware registered *after* a route has served does not
run for that route. Registering everything before starting the server — which is what `app.Run`
does, and what an application normally does — keeps this invisible. An application that reaches the
router itself and registers late gets an error in the log saying so rather than a middleware that
silently never runs.

## The one thing to check in your own code

Expand All @@ -78,7 +92,8 @@ import gofrHTTP "gofr.dev/pkg/gofr/http"
tmpl := gofrHTTP.RouteTemplate(r) // "/users/{id}", or "" if nothing matched
```

`mux.Vars(r)` is **not** affected and needs no change.
`mux.Vars(r)` keeps working and needs no change for any ordinary read. Only an explicit nil check
against the map itself differs -- see the note above.

## Confirming which matcher is active

Expand Down
2 changes: 1 addition & 1 deletion pkg/gofr/gofr.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ func (a *App) setupGraphQL() {
}

// Functional endpoint: served via POST per spec to ensure data safety and consistency.
a.httpServer.router.NewRoute().Methods(http.MethodPost).Path("/graphql").Handler(a.graphqlManager.GetHandler())
a.httpServer.router.Add(http.MethodPost, "/graphql", a.graphqlManager.GetHandler())
}
}

Expand Down
85 changes: 85 additions & 0 deletions pkg/gofr/http/alloc_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//go:build !race

// Allocation counts are only meaningful without the race detector, which adds
// bookkeeping allocations of its own. The guard is excluded from race builds
// rather than loosened to a tolerance that would no longer catch anything.

package http

import (
"net/http"
"net/http/httptest"
"testing"
)

// TestTrieRequestAllocationsDoNotRegress is the guard the optimizations in this
// package lacked.
//
// Every test beside it checks behavior, which all three changes preserve by
// design -- a memoized chain, a skipped empty map and a returned slice are all
// invisible from the outside. So a revert of any of them keeps the suite green
// and gives the allocations back silently. This counts them instead.
//
// The count it pins is 7, while the commit's table quotes 8 allocs/op. They are
// different fixtures, not an inconsistency: this guard drives five middlewares
// and a nopWriter, the benchmark three and a real response writer. The guard's
// job is to notice change, so it is measured where the signal is cleanest rather
// than where the headline number comes from.
func TestTrieRequestAllocationsDoNotRegress(t *testing.T) {
t.Setenv(RouterEnvVar, MatcherTrie)

// Exact, not a ceiling. With headroom this caught only the largest of the three
// savings here: reverting the chain memoization costs five allocations and was
// caught, but the empty-Vars skip costs two and the collect signature one, and
// both slipped under a tolerance wide enough to absorb toolchain drift.
// AllocsPerRun is deterministic for fixed code, so the only thing that moves
// this is a Go or dependency upgrade -- worth a human looking at. If it fails
// after one, re-measure and update the constant in the same commit.
const wantAllocs = 7

r := NewRouter()
// Five middlewares, because that is what newHTTPServer installs and because the
// saving is per middleware: composing the chain per request allocates one
// closure for each. A single middleware would make the difference one
// allocation, small enough to hide under the ceiling's headroom, and the guard
// would pass with the optimization reverted.
for range 5 {
r.UseMiddleware(func(inner http.Handler) http.Handler {
// Returns a NEW handler, as every real middleware does. One that handed
// back `inner` unchanged would allocate nothing when composed, and the
// guard would then pass with the memoization reverted.
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
inner.ServeHTTP(w, req)
})
})
}

r.Add(http.MethodGet, "/bench/ping", http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }))

// The request and writer are built once and reused, as the benchmark beside
// this does. Allocating them inside the measured closure would count
// httptest's own work -- about 17 objects -- and drown the thing being
// guarded. ServeHTTP does not mutate the request it is given; the router
// copies it when it attaches context.
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/bench/ping", http.NoBody)
w := &nopWriter{header: make(http.Header, 4)}

// Warm the lazily built trie index and the per-route chain, so the measured
// runs see the steady state rather than one-time construction.
for range 5 {
r.ServeHTTP(w, req)
}

got := testing.AllocsPerRun(200, func() {
r.ServeHTTP(w, req)
})

if got != wantAllocs {
t.Errorf("trie request path allocates %.0f objects, expected exactly %d -- an "+
"optimization in this package has regressed, or a toolchain change moved the "+
"baseline", got, wantAllocs)
}

t.Logf("trie request path: %.0f allocations", got)
}
Loading
Loading