Skip to content

perf: cache compiled path regexes and cut request validation allocations - #340

Open
emilien-puget wants to merge 13 commits into
pb33f:mainfrom
emilien-puget:path-regex-cache
Open

emilien-puget wants to merge 13 commits into
pb33f:mainfrom
emilien-puget:path-regex-cache

Conversation

@emilien-puget

Copy link
Copy Markdown
Contributor

Goal

Request validation called helpers.GetRegexForPath for every path template on every request and recompiled the same regex each time, although the template is constant per route. This PR caches compiled regexes by template and then applies the same cache discipline to the rest of the request and response hot path: stop rebuilding per-request state, hoist per-call allocations out of loops, and reuse what the validator already built.

It closes the gap with libopenapi, which already caches its compiled JSONPath expressions the same way; the validator's request path was still recompiling on every request.

What changed

Commit Change
01df353 Cache compiled path regexes by template in helpers.GetRegexForPath (package-level sync.Map, LoadOrStore, invalid templates uncached), plus ClearRegexCache()
5501815 Cache brace indices with the compiled regex (GetBraceIndicesForPath); ExtractParamsForOperation no longer appends onto the model's own slice
5f5262e ValidateRequestSchema / ValidateResponseSchema accept pre-built ValidationOptions instead of building defaults per call
a6a047d nonEmptyPathSegments walks the path once (strings.SplitSeq) instead of Split + copy
71259a3 ExtractRawQueryValues returns nil for an empty query
ff19efc GetRenderedSchema returns the cached ReferenceSchema string instead of copying bytes per call
b941576 ValidateResponseHeaders accepts pre-built options through an unexported core; exported signature unchanged
ce3ba8e, 46c57ee, 3150fb0 Benchmark suite: 12 gateway scenarios, including query-heavy, invalid-request, concurrent, format-assertions, strict-server, urlencoded, JSON-response-body and 200-route shapes

Evidence

Paired runs, 10 samples per side, compared with benchstat (i7-1265U, Go 1.26). All bench and perf commits were measured individually with back-to-back before/after runs; the table below compares main against the full branch on all 12 scenarios. Allocation counts are exact (±0%), time is p<0.001 for the shapes this PR touches.

Scenario allocs/op sec/op Δ sec/op
Plain GET (1 uuid path param) 97 → 35 11.1 → 5.7 µs −48%
Param-heavy GET (2 path, query, header) 324 → 94 37.7 → 14.6 µs −61%
POST, JSON body 75 → 63 9.1 → 8.1 µs −25%
Response 200 + header 40 → 33 5.0 → 4.4 µs −12%
Query-heavy GET (4 query + enum header) 122 → 111 18.0 → 17.2 µs ~
Invalid request (error path) 326 → 96 42.5 → 16.7 µs −61%
Concurrent GET, 12 goroutines 97 → 35 6.7 → 2.4 µs −64%
Format assertions on 98 → 36 14.0 → 5.6 µs −60%
Strict server matching 99 → 37 13.2 → 7.4 µs −44%
Form-urlencoded body 97 → 85 13.3 → 11.2 µs −16%
JSON response body 53 → 44 7.7 → 7.4 µs ~
Large route table (200 paths) 114 → 35 12.6 → 5.0 µs −61%
geomean 120 → 59 (−51%) 13.0 → 7.6 µs −42%

Reproduce:

go test -run='^$' -bench='BenchmarkValidateHttpRequestSync|BenchmarkValidateHttpResponse' \
  -count=10 -benchmem -benchtime=50000x ./ .

Mechanism and memory

GetRegexForPath checks a package-level sync.Map keyed by the template string, then compiles and stores with LoadOrStore (concurrent callers race once; the loser's regex is discarded). The original body moved unchanged to an internal compile function. Invalid templates are not cached. The existing config.RegexCache option keeps working; this PR makes the default path fast with no configuration.

For bounding, this follows the cache convention libopenapi introduced for content-keyed caches: utils.ClearJSONPathCache() documents "Call this between document lifecycles in long-running processes to bound memory" and is wired into ClearAllCaches(). This PR adds the same contract as helpers.ClearRegexCache(). An entry holds a few KB (measured 4.3 KB: key string plus compiled regex), so a 1,000-route API costs about 4 MB.

One candidate was measured and rejected, and the negative result is recorded in the commit message: hoisting the per-segment regex out of the parameter loop cost +9% sec/op on the single-parameter shape (three precompute slices, nothing to dedupe), so it was reverted.

Verification

  • go test -race -count=1 ./... — 21/21 packages PASS (run after every change).
  • make lint (golangci-lint, repo config) — 0 issues.
  • New tests: cached pointer identity, errors stay uncached, ClearRegexCache, cached brace indices (identity + invalid template).
  • Remaining allocation leads are documented in the benchmark analysis (router extractPathParams maps, FindStringSubmatchIndex variant, integer math/big fast path); they touch the exported Route shape or vendored-schema behavior and are deliberately out of this diff.

Request validation calls GetRegexForPath on every request, which
rebuilt and recompiled a regex for the same path template each time.
Cache compiled regexes by template in a package-level sync.Map: the
key set is the route table of the document, small and stable for the
process lifetime. Invalid templates stay uncached.

Measured with BenchmarkValidateHttpRequestSync (one valid GET through
ValidateHttpRequestSync, spec with one templated path, n=10):

- sec/op: 9.82us -> 4.09us (-58%)
- B/op: 5.67Ki -> 2.03Ki (-64%)
- allocs/op: 97 -> 44 (-55%)

A warm GetRegexForPath call drops from 3.3-11.5us and 63-204 allocs
to ~20ns and 0 allocs. A cold call keeps its compile cost plus one
cache entry. The config.RegexCache option keeps working at its call
sites; this cache makes the default path fast with no configuration.

Follows the libopenapi cache convention for content-keyed caches:
ClearRegexCache empties the cache between document lifecycles in
long-running processes, the same contract as utils.ClearJSONPathCache.
An entry holds a few KB (measured 4.3 KB: key string plus compiled
regex), so a normal route table costs a few MB at most.
Two changes for the valid-request path, each measured with the request
benchmark suite (one param, three params with query and header, JSON
body; n=10, back-to-back runs):

- Cache brace indices with the compiled regex. New
  GetBraceIndicesForPath returns the cached indices for a template, so
  path parameter validation stops re-scanning the template on every
  request. allocs/op: 44 -> 43 (one param), 108 -> 104 (three params).

- ExtractParamsForOperation no longer appends operation parameters
  onto item.Parameters, which could write into the shared model backing
  array. One side empty returns the model slice untouched; both sides
  non-empty merges with an exact-size slice. allocs/op: 43 -> 39,
  B/op down 1.6-2.4%.

Time deltas sit inside machine noise on this hardware; allocation
counts are exact. The next happy-path lever is GetRenderedSchema,
which re-renders parameter schemas on every request when the schema
cache is disabled and accounts for about a third of the remaining
allocations.
ValidateRequestSchema and ValidateResponseSchema called
config.NewValidationOptions on every validation, which builds a full
default options object first: two fresh schema caches, a content
registry and decoder slices. The internal callers then overwrote the
caches through WithExistingOpts and dropped the fresh ones, so every
request allocated options and caches that it never used.

Add an optional ValidationOptions field to both input structs. When
set, the function uses it directly and skips building defaults. The
options are treated as read-only during validation. The internal body
validators now pass their built options.

Measured with BenchmarkValidateHttpRequestSyncJSONBody (one valid POST
with a JSON body, n=10, back-to-back runs):

- sec/op: 8.64us -> 7.29us (-15.6%)
- B/op: 5.75Ki -> 5.21Ki (-9.4%)
- allocs/op: 75 -> 66 (-12%)

The two no-body request shapes are unchanged, as expected: they never
enter these functions. ValidateResponseHeaders still builds options
per call and stays open for a follow-up.
nonEmptyPathSegments ran strings.Split and then copied the non-empty
segments into a second slice: two allocations per call, and the
validator calls it for the template and the request path on every
request. Walk the path once with strings.SplitSeq and write non-empty
segments straight into a slice sized by the separator count.

Measured with the request benchmark suite (n=10, back-to-back runs):

- ValidateHttpRequestSync: sec/op 4.69us -> 4.31us (-8.0%),
  allocs/op 39 -> 37
- allocs/op across all shapes: -2 to -3 per request

The candidate that hoisted the per-segment regex out of the parameter
loop was measured in the same round and rejected: with one path
parameter nothing is deduped and the three precompute slices cost more
than they save (+9% sec/op on the plain shape).
BenchmarkValidateHttpResponse measures one valid 200 response with a
response header: routing, response header validation and response body
validation. The benchmark suite now covers the four gateway shapes:
plain GET, parameter-heavy GET, POST with JSON body, and response
validation.
ExtractRawQueryValues allocated its map on every call, even for
requests without a query string. Return nil instead; callers only read
the map, and nil map reads are safe.

Measured with BenchmarkValidateHttpRequestSync (no query params,
n=10, back-to-back runs): allocs/op 37 -> 36, B/op -2.5%.
GetRenderedSchema copied the cached rendered schema into a new string
on every call, even on a cache hit, because the entry stores the
rendered schema as bytes. The entry also carries ReferenceSchema, the
string form of the same bytes; return it when present.

Measured with BenchmarkValidateHttpRequestSyncPathQueryHeader (n=10,
back-to-back runs): allocs/op 98 -> 94, sec/op -3.1%.
ValidateResponseHeaders built default options on every call, which
allocates two fresh schema caches and a content registry that the
caller immediately overwrites with shared options. Split the flow
into an unexported core that takes pre-built options; the exported
function keeps its signature and builds options for direct callers.
The internal body validator now passes its built options.

Measured with BenchmarkValidateHttpResponse (one valid 200 response
with a response header, n=10, back-to-back runs):

- sec/op: 4.46us -> 4.05us (-9.3%)
- B/op: 2.72Ki -> 2.20Ki (-19%)
- allocs/op: 40 -> 33 (-17.5%)
Three benchmark scenarios complete the gateway coverage:

- Query-heavy GET: four query parameters and an enum header parameter.
- Invalid GET: an integer path parameter that fails its schema, the
  error-construction shape.
- Parallel GET: the plain shape from many goroutines, which is how a
  gateway shares one validator and its caches.

Main vs branch, n=10, benchstat: allocs/op 97 -> 35 (plain),
324 -> 94 (parameter-heavy), 326 -> 96 (invalid), 122 -> 111 (query),
40 -> 33 (response). Concurrent requests hold the cache win:
-64% allocs and -61% wall time at 12 goroutines.
Five scenarios close the coverage gaps in the benchmark suite: format
assertions enabled, strict server matching, a form-urlencoded body, a
JSON response body, and a document with 200 path items.

Main vs branch, n=10, benchstat, allocs/op:

- format assertions: 98 -> 36 (-63%), sec/op -60%
- strict server matching: 99 -> 37 (-63%), sec/op -44%
- form-urlencoded body: 97 -> 85 (-12%)
- JSON response body: 53 -> 44 (-17%)
- large route table (200 paths): 114 -> 35 (-69%), sec/op -60%

The regex cache dominates the win on shapes that route through many
templates; the parameter and body shapes hold smaller, steady gains.
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.81%. Comparing base (754a5b7) to head (5371269).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #340      +/-   ##
==========================================
+ Coverage   98.74%   98.81%   +0.06%     
==========================================
  Files          94       94              
  Lines       12310    12344      +34     
==========================================
+ Hits        12156    12198      +42     
+ Misses        129      125       -4     
+ Partials       25       21       -4     
Flag Coverage Δ
unittests 98.81% <100.00%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- ExtractRawQueryValues: nil return for an empty query.
- ExtractParamsForOperation: both sides empty, one side empty, and the
  exact-size merge, each asserting the model slice stays untouched.
- GetRegexForPath: regexp compilation failure on an invalid custom
  pattern, and rejection of capturing groups in custom patterns.

The one uncovered line left in compilePathRegexEntry is the
bytes.Buffer write error, which cannot occur: buffer writes panic on
overflow instead of returning an error.
The continue after GetBraceIndicesForPath fails protects against a
user-supplied regex cache that holds a regex under a segment key with
unbalanced braces: the regex matches, but the segment carries no brace
indices. Pin the defensive behavior: the segment is skipped, the
declared path parameter still validates, and no error is reported for
the skipped segment.
The pattern buffer writes cannot fail: bytes.Buffer panics on overflow
instead of returning an error, so the fmt.Fprintf error branch was
unreachable. Assembling the pattern with strings.Builder removes the
dead error check and the fmt call with its interface boxing.

Cold compile path, paired n=10: 93 -> 90 allocs/op (-3.2%, p<0.001),
sec/op neutral.
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