Repository navigation
perf: cache compiled path regexes and cut request validation allocations - #340
Open
emilien-puget wants to merge 13 commits into
Open
emilien-puget wants to merge 13 commits into
emilien-puget wants to merge 13 commits into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- 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.
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.
Goal
Request validation called
helpers.GetRegexForPathfor 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
01df353helpers.GetRegexForPath(package-levelsync.Map,LoadOrStore, invalid templates uncached), plusClearRegexCache()5501815GetBraceIndicesForPath);ExtractParamsForOperationno longer appends onto the model's own slice5f5262eValidateRequestSchema/ValidateResponseSchemaaccept pre-builtValidationOptionsinstead of building defaults per calla6a047dnonEmptyPathSegmentswalks the path once (strings.SplitSeq) instead ofSplit+ copy71259a3ExtractRawQueryValuesreturns nil for an empty queryff19efcGetRenderedSchemareturns the cachedReferenceSchemastring instead of copying bytes per callb941576ValidateResponseHeadersaccepts pre-built options through an unexported core; exported signature unchangedce3ba8e,46c57ee,3150fb0Evidence
Paired runs, 10 samples per side, compared with
benchstat(i7-1265U, Go 1.26). Allbenchandperfcommits were measured individually with back-to-back before/after runs; the table below comparesmainagainst the full branch on all 12 scenarios. Allocation counts are exact (±0%), time is p<0.001 for the shapes this PR touches.Reproduce:
Mechanism and memory
GetRegexForPathchecks a package-levelsync.Mapkeyed by the template string, then compiles and stores withLoadOrStore(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 existingconfig.RegexCacheoption 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 intoClearAllCaches(). This PR adds the same contract ashelpers.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.ClearRegexCache, cached brace indices (identity + invalid template).extractPathParamsmaps,FindStringSubmatchIndexvariant, integermath/bigfast path); they touch the exportedRouteshape or vendored-schema behavior and are deliberately out of this diff.