[TT-17049] Replace unbounded regex caches with a bounded LRU cache to prevent memory leaks#8239
Conversation
🎯 Recommended Merge TargetsBased on JIRA ticket TT-17049: Memory fragmentation and unbounded RSS growth in CoProcess bridge Fix Version: Tyk 5.8.15Required:
Fix Version: Tyk 5.13.1Required:
Fix Version: Tyk 5.14.0
Required:
📋 Workflow
|
|
This pull request introduces a comprehensive set of improvements focused on memory stability, performance, and runtime efficiency. The core change replaces unbounded regular expression caches with a bounded LRU (Least Recently Used) cache, preventing potential memory leaks caused by a large number of unique regex patterns. This is complemented by a significant performance optimization that removes a legacy defensive Additionally, the PR integrates Files Changed Analysis
Architecture & Impact Assessment
System Impact Diagramgraph TD
subgraph "Configuration (tyk.conf)"
A[regexp_cache_max_entries]
B[disable_regexp_cache_bound]
C[disable_auto_max_procs]
end
subgraph "Gateway Startup"
D["gateway.afterConfSetup()"]
end
subgraph "Affected Components"
E["Regex Caches (regexp, httputil)"]
F["Go Runtime (GOMAXPROCS)"]
G["Middleware Engine (BaseMiddleware)"]
end
A --|Configures LRU bounds|--> D
B --|Opts-out of bounds|--> D
C --|Controls tuning|--> D
D --|Initializes Bounded LRU|--> E
D --|Tunes via automaxprocs|--> F
G --|Race condition fixed in Copy|--> G
Scope Discovery & Context Expansion
Metadata
Powered by Visor from Probelabs Last updated: 2026-05-28T12:25:36.581Z | Triggered by: pr_updated | Commit: ce523cd 💡 TIP: You can chat with Visor using |
✅ Security Check PassedNo security issues found – changes LGTM. ✅ Architecture Check PassedNo architecture issues found – changes LGTM. ✅ Performance Check PassedNo performance issues found – changes LGTM. Quality Issues (1)
Powered by Visor from Probelabs Last updated: 2026-05-28T12:24:17.235Z | Triggered by: pr_updated | Commit: ce523cd 💡 TIP: You can chat with Visor using |
vladzabolotnyi
left a comment
There was a problem hiding this comment.
Insane piece of work! Well done 🚀
🚨 Jira Linter FailedCommit: The Jira linter failed to validate your PR. Please check the error details below: 🔍 Click to view error detailsNext Steps
This comment will be automatically deleted once the linter passes. |
PR SummaryThis PR fixes two separate production regressions reported by customer RBFCU on 8 Gateway nodes running 5.9.2: (1) unbounded RSS growth caused by Jira Alignment
Criteria gaps:
Unit & Integration Test Coverage
Notes on test quality:
UI Test Coverage (Playwright)No frontend files changed — section not applicable. Security FindingsThe SentinelOne CNS Scan failure lists 24 issues across No security issues found in the changed Go code. Code Quality Issues
Tyk Domain Issues
What was done well
Gap Summary
VerdictNEEDS DISCUSSION Two questions need answers before this can be approved:
The dead constants ( Core logic is sound, concurrent tests are thorough, and the nil-logger fix is clean. This is close to approval. Review by Quinn · Tyk QA · 2026-05-28 |
|
|
/release to release-5.13 |
|
/release to release-5.8 |
|
✅ Cherry-pick successful. A PR was created: #8274 |
|
|
… prevent memory leaks (#8239) <!-- Provide a general summary of your changes in the Title above --> ## Description # TT-17049 — benchmark results Hot-path benchmarks for `github.com/TykTechnologies/tyk/regexp` and `github.com/TykTechnologies/tyk/internal/httputil`, measured to validate that bounding the regex caches with `expirable.LRU` did not regress request-handling cost. - **Run date:** 2026-05-21 - **Host:** Apple M1 Pro · 10 cores · 16 GB RAM · macOS 15.6.1 - **Toolchain:** host `go1.25.1 darwin/arm64`; benchmarks executed via Tyk's local `go` wrapper inside a Docker container (`go1.25.9 linux/arm64`), which is why the bench output reports `goos: linux, goarch: arm64`. - **Iterations:** `-count=10 -benchtime=1s` — gives real 95% CIs on every row (the previous `-count=5` runs printed `± ∞ ¹`). ## What changed Two independent wins compose: 1. **Bounded LRU swap** (`internal/cache.Cache` → `expirable.LRU`). On its own this adds a small overhead from the LRU's recency-update mutex on every Get — visible as the small `Replace*`/`FindAll*` regressions above. Allocations stay identical. 2. **Removed defensive `re.Copy()`** in `cache.getRegexp`. The Copy was added in 2018-06-06 (PR #1689), eight months before Go 1.12 (Feb 2019) made `*regexp.Regexp` safe for concurrent reads without copying. Once removed, every cache hit drops 1 allocation and 160 B of garbage, and net wall time on Compile/Match drops well below master. The Copy was pre-existing on master, not added by TT-17049. The Go deprecation tooling surfaced the opportunity. ## Methodology ```sh # Master worktree: git worktree add -f /tmp/tyk-master-bench master cd /tmp/tyk-master-bench go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... > master.bench # TT-17049 branch (includes new httputil bench): go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... ./internal/httputil/... > branch.bench benchstat master.bench branch.bench > benchstat.txt ``` ## Results — master vs TT-17049 ### Wall time | Benchmark | Master (ns/op) | Branch (ns/op) | Δ | p | |------------------------------------|---------------:|---------------:|-------------:|------:| | `RegExpCompile` | 121.90 | **86.72** | **−28.86%** | 0.000 | | `RegExpCompilePOSIX` | 123.05 | **74.70** | **−39.29%** | 0.000 | | `RegExpMustCompile` | 124.90 | **87.91** | **−29.62%** | 0.000 | | `RegExpMustCompilePOSIX` | 124.05 | **75.48** | **−39.15%** | 0.000 | | `RegExpMatchString` | 193.90 | **164.50** | **−15.14%** | 0.000 | | `RegExpMatch` | 195.00 | **166.30** | **−14.74%** | 0.000 | | `RegExpString` | 0.5086 | 0.5130 | +0.87% (sub-ns) | 0.000 | | `RegexpReplaceAllString` | 74.79 | 77.46 | +3.58% | 0.002 | | `RegexpReplaceAllLiteralString` | 74.71 | 78.41 | +4.95% | 0.000 | | `RegexpReplaceAllStringFunc` | 106.80 | 112.20 | +5.06% | 0.000 | | `RegexpFindAllString` | 82.02 | 85.20 | +3.89% | 0.000 | | `RegexpFindAllStringSubmatch` | 82.20 | 85.71 | +4.26% | 0.000 | | `RegexpFindStringSubmatch` | 77.05 | 76.79 | ~ | 0.853 | | **Geomean (comparable subset)** | | | **−12.89%** | | `~` = no statistically significant change (p > 0.05). ### Memory | Benchmark | Master (B/op) | Branch (B/op) | Δ | p | |--------------------------|--------------:|--------------:|------------:|------:| | `RegExpCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatchString` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatch` | 176 | **16** | **−90.91%** | 0.000 | | (others: ReplaceAll/Find)| 0 | 0 | — | — | | **Geomean delta** | | | **−66.94%** | | ### Allocations | Benchmark | Master | Branch | Δ | p | |-----------------------|------:|------:|------------:|------:| | Compile / Match paths | 2 | **1** | **−50%** | 0.000 | | Replace / Find paths | 0 | 0 | — | — | | **Geomean delta** | | | **−27.38%** | | ## New benchmarks (branch-only) These cover paths added by TT-17049 — no master comparator since the LRU API didn't exist there. | Benchmark | ns/op | B/op | allocs/op | |---------------------------------|-------:|------:|----------:| | `Cache_HitParallel` | 164.4 | 0 | 0 | | `Cache_MissAndAdd_Saturated` | 670.4 | 160 | 4 | | `Regexp_MatchString_Hit` | 156.7 | 16 | 1 | | `Regexp_Compile_Hit` | 73.94 | 16 | 1 | | `Regexp_Compile_Miss` | 4136 | 5815 | 69 | | `PreparePathRegexp_Hit` | 164.0 | 47 | 2 | ### What these tell us - **Cache hit is ~56× faster than a cold compile** (74 ns vs 4136 ns). Customer pattern sizes are ~70 KB vs the trivial `^abc.*$` benchmarked here, so production hit-vs-miss savings are even larger. - **HitParallel at 164 ns / 0 allocs at GOMAXPROCS=10:** the `expirable.LRU` mutex is not pathological under concurrent reads. The plan's "P1 sharded-LRU contingency" remains unwarranted. - **Saturated MissAndAdd at 670 ns/op:** if a workload's distinct-pattern working set exceeds `RegexpCacheMaxEntries`, every miss costs `(670 + 4136) ≈ 4.8 µs` (eviction + cold compile). That's the 5-minute eviction-summary warning's reason to exist. - **`PreparePathRegexp_Hit` 2 allocs:** the `fmt.Sprintf` cache key, not the LRU itself. A future micro-opt (pooled `strings.Builder`) could remove these; not worth doing now. ## Why the Copy() removal works `cache.getRegexp` previously returned `v.(*regexp.Regexp).Copy()` for defensive concurrency. As of Go 1.12 (Feb 2019), the stdlib documents `*regexp.Regexp` as safe for concurrent reads via its `Match*`, `Find*`, `ReplaceAll*`, etc. methods without copying. Only `Longest()` mutates — and Tyk's `Regexp.Longest()` wrapper is dead code (a logic-flipped nil-guard that would only invoke the underlying `Longest()` on a nil receiver, panicking). The race tests in `regexp/cache_test.go::TestCache_ConcurrentReads_SharedRegex` hammer 14 read methods × 200 goroutines × 200 iterations on a shared cached regex and pass cleanly under `go test -race`. ## Related Issue <!-- This project only accepts pull requests related to open issues. --> <!-- If suggesting a new feature or change, please discuss it in an issue first. --> <!-- If fixing a bug, there should be an issue describing it with steps to reproduce. --> <!-- OSS: Please link to the issue here. Tyk: please create/link the JIRA ticket. --> ## Motivation and Context <!-- Why is this change required? What problem does it solve? --> ## How This Has Been Tested <!-- Please describe in detail how you tested your changes --> <!-- Include details of your testing environment, and the tests --> <!-- you ran to see how your change affects other areas of the code, etc. --> <!-- This information is helpful for reviewers and QA. --> ## Screenshots (if appropriate) ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Refactoring or add test (improvements in base code or adds test coverage to functionality) ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply --> <!-- If there are no documentation updates required, mark the item as checked. --> <!-- Raise up any additional concerns not covered by the checklist. --> - [ ] I ensured that the documentation is up to date - [ ] I explained why this PR updates go.mod in detail with reasoning why it's required - [ ] I would like a code coverage CI quality gate exception and have explained why <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17049" title="TT-17049" target="_blank">TT-17049</a> </summary> | | | |---------|----| | Status | In Test | | Summary | Memory fragmentation and unbounded RSS growth in CoProcess bridge | Generated at: 2026-05-28 12:22:16 </details> <!---TykTechnologies/jira-linter ends here-->
… prevent memory leaks (#8239) <!-- Provide a general summary of your changes in the Title above --> Hot-path benchmarks for `github.com/TykTechnologies/tyk/regexp` and `github.com/TykTechnologies/tyk/internal/httputil`, measured to validate that bounding the regex caches with `expirable.LRU` did not regress request-handling cost. - **Run date:** 2026-05-21 - **Host:** Apple M1 Pro · 10 cores · 16 GB RAM · macOS 15.6.1 - **Toolchain:** host `go1.25.1 darwin/arm64`; benchmarks executed via Tyk's local `go` wrapper inside a Docker container (`go1.25.9 linux/arm64`), which is why the bench output reports `goos: linux, goarch: arm64`. - **Iterations:** `-count=10 -benchtime=1s` — gives real 95% CIs on every row (the previous `-count=5` runs printed `± ∞ ¹`). Two independent wins compose: 1. **Bounded LRU swap** (`internal/cache.Cache` → `expirable.LRU`). On its own this adds a small overhead from the LRU's recency-update mutex on every Get — visible as the small `Replace*`/`FindAll*` regressions above. Allocations stay identical. 2. **Removed defensive `re.Copy()`** in `cache.getRegexp`. The Copy was added in 2018-06-06 (PR #1689), eight months before Go 1.12 (Feb 2019) made `*regexp.Regexp` safe for concurrent reads without copying. Once removed, every cache hit drops 1 allocation and 160 B of garbage, and net wall time on Compile/Match drops well below master. The Copy was pre-existing on master, not added by TT-17049. The Go deprecation tooling surfaced the opportunity. ```sh git worktree add -f /tmp/tyk-master-bench master cd /tmp/tyk-master-bench go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... > master.bench go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... ./internal/httputil/... > branch.bench benchstat master.bench branch.bench > benchstat.txt ``` | Benchmark | Master (ns/op) | Branch (ns/op) | Δ | p | |------------------------------------|---------------:|---------------:|-------------:|------:| | `RegExpCompile` | 121.90 | **86.72** | **−28.86%** | 0.000 | | `RegExpCompilePOSIX` | 123.05 | **74.70** | **−39.29%** | 0.000 | | `RegExpMustCompile` | 124.90 | **87.91** | **−29.62%** | 0.000 | | `RegExpMustCompilePOSIX` | 124.05 | **75.48** | **−39.15%** | 0.000 | | `RegExpMatchString` | 193.90 | **164.50** | **−15.14%** | 0.000 | | `RegExpMatch` | 195.00 | **166.30** | **−14.74%** | 0.000 | | `RegExpString` | 0.5086 | 0.5130 | +0.87% (sub-ns) | 0.000 | | `RegexpReplaceAllString` | 74.79 | 77.46 | +3.58% | 0.002 | | `RegexpReplaceAllLiteralString` | 74.71 | 78.41 | +4.95% | 0.000 | | `RegexpReplaceAllStringFunc` | 106.80 | 112.20 | +5.06% | 0.000 | | `RegexpFindAllString` | 82.02 | 85.20 | +3.89% | 0.000 | | `RegexpFindAllStringSubmatch` | 82.20 | 85.71 | +4.26% | 0.000 | | `RegexpFindStringSubmatch` | 77.05 | 76.79 | ~ | 0.853 | | **Geomean (comparable subset)** | | | **−12.89%** | | `~` = no statistically significant change (p > 0.05). | Benchmark | Master (B/op) | Branch (B/op) | Δ | p | |--------------------------|--------------:|--------------:|------------:|------:| | `RegExpCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatchString` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatch` | 176 | **16** | **−90.91%** | 0.000 | | (others: ReplaceAll/Find)| 0 | 0 | — | — | | **Geomean delta** | | | **−66.94%** | | | Benchmark | Master | Branch | Δ | p | |-----------------------|------:|------:|------------:|------:| | Compile / Match paths | 2 | **1** | **−50%** | 0.000 | | Replace / Find paths | 0 | 0 | — | — | | **Geomean delta** | | | **−27.38%** | | These cover paths added by TT-17049 — no master comparator since the LRU API didn't exist there. | Benchmark | ns/op | B/op | allocs/op | |---------------------------------|-------:|------:|----------:| | `Cache_HitParallel` | 164.4 | 0 | 0 | | `Cache_MissAndAdd_Saturated` | 670.4 | 160 | 4 | | `Regexp_MatchString_Hit` | 156.7 | 16 | 1 | | `Regexp_Compile_Hit` | 73.94 | 16 | 1 | | `Regexp_Compile_Miss` | 4136 | 5815 | 69 | | `PreparePathRegexp_Hit` | 164.0 | 47 | 2 | - **Cache hit is ~56× faster than a cold compile** (74 ns vs 4136 ns). Customer pattern sizes are ~70 KB vs the trivial `^abc.*$` benchmarked here, so production hit-vs-miss savings are even larger. - **HitParallel at 164 ns / 0 allocs at GOMAXPROCS=10:** the `expirable.LRU` mutex is not pathological under concurrent reads. The plan's "P1 sharded-LRU contingency" remains unwarranted. - **Saturated MissAndAdd at 670 ns/op:** if a workload's distinct-pattern working set exceeds `RegexpCacheMaxEntries`, every miss costs `(670 + 4136) ≈ 4.8 µs` (eviction + cold compile). That's the 5-minute eviction-summary warning's reason to exist. - **`PreparePathRegexp_Hit` 2 allocs:** the `fmt.Sprintf` cache key, not the LRU itself. A future micro-opt (pooled `strings.Builder`) could remove these; not worth doing now. `cache.getRegexp` previously returned `v.(*regexp.Regexp).Copy()` for defensive concurrency. As of Go 1.12 (Feb 2019), the stdlib documents `*regexp.Regexp` as safe for concurrent reads via its `Match*`, `Find*`, `ReplaceAll*`, etc. methods without copying. Only `Longest()` mutates — and Tyk's `Regexp.Longest()` wrapper is dead code (a logic-flipped nil-guard that would only invoke the underlying `Longest()` on a nil receiver, panicking). The race tests in `regexp/cache_test.go::TestCache_ConcurrentReads_SharedRegex` hammer 14 read methods × 200 goroutines × 200 iterations on a shared cached regex and pass cleanly under `go test -race`. <!-- This project only accepts pull requests related to open issues. --> <!-- If suggesting a new feature or change, please discuss it in an issue first. --> <!-- If fixing a bug, there should be an issue describing it with steps to reproduce. --> <!-- OSS: Please link to the issue here. Tyk: please create/link the JIRA ticket. --> <!-- Why is this change required? What problem does it solve? --> <!-- Please describe in detail how you tested your changes --> <!-- Include details of your testing environment, and the tests --> <!-- you ran to see how your change affects other areas of the code, etc. --> <!-- This information is helpful for reviewers and QA. --> <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Refactoring or add test (improvements in base code or adds test coverage to functionality) <!-- Go over all the following points, and put an `x` in all the boxes that apply --> <!-- If there are no documentation updates required, mark the item as checked. --> <!-- Raise up any additional concerns not covered by the checklist. --> - [ ] I ensured that the documentation is up to date - [ ] I explained why this PR updates go.mod in detail with reasoning why it's required - [ ] I would like a code coverage CI quality gate exception and have explained why <!---TykTechnologies/jira-linter starts here--> <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17049" title="TT-17049" target="_blank">TT-17049</a> </summary> | | | |---------|----| | Status | In Test | | Summary | Memory fragmentation and unbounded RSS growth in CoProcess bridge | Generated at: 2026-05-28 12:22:16 </details> <!---TykTechnologies/jira-linter ends here-->
…h a bounded LRU cache to prevent memory leaks (#8239) (#8275) $(gh pr view 8275 --json body -q .body) --- Requested by: <@U03HH6WGUCC> Trace: d11557f2bd7b4d3d7167883a74bfbb71 Generated with Visor AI Assistant <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17049" title="TT-17049" target="_blank">TT-17049</a> </summary> | | | |---------|----| | Status | Merge | | Summary | Memory fragmentation and unbounded RSS growth in CoProcess bridge | Generated at: 2026-06-04 09:28:30 </details> <!---TykTechnologies/jira-linter ends here--> --------- Co-authored-by: Tyk Bot <[email protected]> Co-authored-by: Florencia Caballero <[email protected]> Co-authored-by: Leonid Bugaev <[email protected]>
…th a bounded LRU cache to prevent memory leaks (#8239) (#8274) [TT-17049] Replace unbounded regex caches with a bounded LRU cache to prevent memory leaks (#8239) <!-- Provide a general summary of your changes in the Title above --> ## Description # TT-17049 — benchmark results Hot-path benchmarks for `github.com/TykTechnologies/tyk/regexp` and `github.com/TykTechnologies/tyk/internal/httputil`, measured to validate that bounding the regex caches with `expirable.LRU` did not regress request-handling cost. - **Run date:** 2026-05-21 - **Host:** Apple M1 Pro · 10 cores · 16 GB RAM · macOS 15.6.1 - **Toolchain:** host `go1.25.1 darwin/arm64`; benchmarks executed via Tyk's local `go` wrapper inside a Docker container (`go1.25.9 linux/arm64`), which is why the bench output reports `goos: linux, goarch: arm64`. - **Iterations:** `-count=10 -benchtime=1s` — gives real 95% CIs on every row (the previous `-count=5` runs printed `± ∞ ¹`). ## What changed Two independent wins compose: 1. **Bounded LRU swap** (`internal/cache.Cache` → `expirable.LRU`). On its own this adds a small overhead from the LRU's recency-update mutex on every Get — visible as the small `Replace*`/`FindAll*` regressions above. Allocations stay identical. 2. **Removed defensive `re.Copy()`** in `cache.getRegexp`. The Copy was added in 2018-06-06 (PR #1689), eight months before Go 1.12 (Feb 2019) made `*regexp.Regexp` safe for concurrent reads without copying. Once removed, every cache hit drops 1 allocation and 160 B of garbage, and net wall time on Compile/Match drops well below master. The Copy was pre-existing on master, not added by TT-17049. The Go deprecation tooling surfaced the opportunity. ## Methodology ```sh # Master worktree: git worktree add -f /tmp/tyk-master-bench master cd /tmp/tyk-master-bench go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... > master.bench # TT-17049 branch (includes new httputil bench): go test -bench='^Benchmark' -benchmem -run='^$' -count=10 -benchtime=1s \ ./regexp/... ./internal/httputil/... > branch.bench benchstat master.bench branch.bench > benchstat.txt ``` ## Results — master vs TT-17049 ### Wall time | Benchmark | Master (ns/op) | Branch (ns/op) | Δ | p | |------------------------------------|---------------:|---------------:|-------------:|------:| | `RegExpCompile` | 121.90 | **86.72** | **−28.86%** | 0.000 | | `RegExpCompilePOSIX` | 123.05 | **74.70** | **−39.29%** | 0.000 | | `RegExpMustCompile` | 124.90 | **87.91** | **−29.62%** | 0.000 | | `RegExpMustCompilePOSIX` | 124.05 | **75.48** | **−39.15%** | 0.000 | | `RegExpMatchString` | 193.90 | **164.50** | **−15.14%** | 0.000 | | `RegExpMatch` | 195.00 | **166.30** | **−14.74%** | 0.000 | | `RegExpString` | 0.5086 | 0.5130 | +0.87% (sub-ns) | 0.000 | | `RegexpReplaceAllString` | 74.79 | 77.46 | +3.58% | 0.002 | | `RegexpReplaceAllLiteralString` | 74.71 | 78.41 | +4.95% | 0.000 | | `RegexpReplaceAllStringFunc` | 106.80 | 112.20 | +5.06% | 0.000 | | `RegexpFindAllString` | 82.02 | 85.20 | +3.89% | 0.000 | | `RegexpFindAllStringSubmatch` | 82.20 | 85.71 | +4.26% | 0.000 | | `RegexpFindStringSubmatch` | 77.05 | 76.79 | ~ | 0.853 | | **Geomean (comparable subset)** | | | **−12.89%** | | `~` = no statistically significant change (p > 0.05). ### Memory | Benchmark | Master (B/op) | Branch (B/op) | Δ | p | |--------------------------|--------------:|--------------:|------------:|------:| | `RegExpCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompile` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMustCompilePOSIX` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatchString` | 176 | **16** | **−90.91%** | 0.000 | | `RegExpMatch` | 176 | **16** | **−90.91%** | 0.000 | | (others: ReplaceAll/Find)| 0 | 0 | — | — | | **Geomean delta** | | | **−66.94%** | | ### Allocations | Benchmark | Master | Branch | Δ | p | |-----------------------|------:|------:|------------:|------:| | Compile / Match paths | 2 | **1** | **−50%** | 0.000 | | Replace / Find paths | 0 | 0 | — | — | | **Geomean delta** | | | **−27.38%** | | ## New benchmarks (branch-only) These cover paths added by TT-17049 — no master comparator since the LRU API didn't exist there. | Benchmark | ns/op | B/op | allocs/op | |---------------------------------|-------:|------:|----------:| | `Cache_HitParallel` | 164.4 | 0 | 0 | | `Cache_MissAndAdd_Saturated` | 670.4 | 160 | 4 | | `Regexp_MatchString_Hit` | 156.7 | 16 | 1 | | `Regexp_Compile_Hit` | 73.94 | 16 | 1 | | `Regexp_Compile_Miss` | 4136 | 5815 | 69 | | `PreparePathRegexp_Hit` | 164.0 | 47 | 2 | ### What these tell us - **Cache hit is ~56× faster than a cold compile** (74 ns vs 4136 ns). Customer pattern sizes are ~70 KB vs the trivial `^abc.*$` benchmarked here, so production hit-vs-miss savings are even larger. - **HitParallel at 164 ns / 0 allocs at GOMAXPROCS=10:** the `expirable.LRU` mutex is not pathological under concurrent reads. The plan's "P1 sharded-LRU contingency" remains unwarranted. - **Saturated MissAndAdd at 670 ns/op:** if a workload's distinct-pattern working set exceeds `RegexpCacheMaxEntries`, every miss costs `(670 + 4136) ≈ 4.8 µs` (eviction + cold compile). That's the 5-minute eviction-summary warning's reason to exist. - **`PreparePathRegexp_Hit` 2 allocs:** the `fmt.Sprintf` cache key, not the LRU itself. A future micro-opt (pooled `strings.Builder`) could remove these; not worth doing now. ## Why the Copy() removal works `cache.getRegexp` previously returned `v.(*regexp.Regexp).Copy()` for defensive concurrency. As of Go 1.12 (Feb 2019), the stdlib documents `*regexp.Regexp` as safe for concurrent reads via its `Match*`, `Find*`, `ReplaceAll*`, etc. methods without copying. Only `Longest()` mutates — and Tyk's `Regexp.Longest()` wrapper is dead code (a logic-flipped nil-guard that would only invoke the underlying `Longest()` on a nil receiver, panicking). The race tests in `regexp/cache_test.go::TestCache_ConcurrentReads_SharedRegex` hammer 14 read methods × 200 goroutines × 200 iterations on a shared cached regex and pass cleanly under `go test -race`. ## Related Issue <!-- This project only accepts pull requests related to open issues. --> <!-- If suggesting a new feature or change, please discuss it in an issue first. --> <!-- If fixing a bug, there should be an issue describing it with steps to reproduce. --> <!-- OSS: Please link to the issue here. Tyk: please create/link the JIRA ticket. --> ## Motivation and Context <!-- Why is this change required? What problem does it solve? --> ## How This Has Been Tested <!-- Please describe in detail how you tested your changes --> <!-- Include details of your testing environment, and the tests --> <!-- you ran to see how your change affects other areas of the code, etc. --> <!-- This information is helpful for reviewers and QA. --> ## Screenshots (if appropriate) ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Refactoring or add test (improvements in base code or adds test coverage to functionality) ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply --> <!-- If there are no documentation updates required, mark the item as checked. --> <!-- Raise up any additional concerns not covered by the checklist. --> - [ ] I ensured that the documentation is up to date - [ ] I explained why this PR updates go.mod in detail with reasoning why it's required - [ ] I would like a code coverage CI quality gate exception and have explained why <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17049" title="TT-17049" target="_blank">TT-17049</a> </summary> | | | |---------|----| | Status | Merge | | Summary | Memory fragmentation and unbounded RSS growth in CoProcess bridge | Generated at: 2026-06-03 12:45:37 </details> <!---TykTechnologies/jira-linter ends here--> [TT-17049]: https://tyktech.atlassian.net/browse/TT-17049?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: Florencia Caballero <[email protected]>



Description
TT-17049 — benchmark results
Hot-path benchmarks for
github.com/TykTechnologies/tyk/regexpandgithub.com/TykTechnologies/tyk/internal/httputil, measured to validatethat bounding the regex caches with
expirable.LRUdid not regressrequest-handling cost.
go1.25.1 darwin/arm64; benchmarks executed viaTyk's local
gowrapper inside a Docker container (go1.25.9 linux/arm64),which is why the bench output reports
goos: linux, goarch: arm64.-count=10 -benchtime=1s— gives real 95% CIs on everyrow (the previous
-count=5runs printed± ∞ ¹).What changed
Two independent wins compose:
Bounded LRU swap (
internal/cache.Cache→expirable.LRU). On itsown this adds a small overhead from the LRU's recency-update mutex on
every Get — visible as the small
Replace*/FindAll*regressionsabove. Allocations stay identical.
Removed defensive
re.Copy()incache.getRegexp. The Copy wasadded in 2018-06-06 (PR NormalisePath and RegExp optimizations #1689), eight months before Go 1.12 (Feb 2019)
made
*regexp.Regexpsafe for concurrent reads without copying. Onceremoved, every cache hit drops 1 allocation and 160 B of garbage, and
net wall time on Compile/Match drops well below master.
The Copy was pre-existing on master, not added by TT-17049. The Go
deprecation tooling surfaced the opportunity.
Methodology
Results — master vs TT-17049
Wall time
RegExpCompileRegExpCompilePOSIXRegExpMustCompileRegExpMustCompilePOSIXRegExpMatchStringRegExpMatchRegExpStringRegexpReplaceAllStringRegexpReplaceAllLiteralStringRegexpReplaceAllStringFuncRegexpFindAllStringRegexpFindAllStringSubmatchRegexpFindStringSubmatch~= no statistically significant change (p > 0.05).Memory
RegExpCompileRegExpCompilePOSIXRegExpMustCompileRegExpMustCompilePOSIXRegExpMatchStringRegExpMatchAllocations
New benchmarks (branch-only)
These cover paths added by TT-17049 — no master comparator since the LRU
API didn't exist there.
Cache_HitParallelCache_MissAndAdd_SaturatedRegexp_MatchString_HitRegexp_Compile_HitRegexp_Compile_MissPreparePathRegexp_HitWhat these tell us
Cache hit is ~56× faster than a cold compile (74 ns vs 4136 ns).
Customer pattern sizes are ~70 KB vs the trivial
^abc.*$benchmarkedhere, so production hit-vs-miss savings are even larger.
HitParallel at 164 ns / 0 allocs at GOMAXPROCS=10: the
expirable.LRUmutex is not pathological under concurrent reads.The plan's "P1 sharded-LRU contingency" remains unwarranted.
Saturated MissAndAdd at 670 ns/op: if a workload's distinct-pattern
working set exceeds
RegexpCacheMaxEntries, every miss costs(670 + 4136) ≈ 4.8 µs(eviction + cold compile). That's the5-minute eviction-summary warning's reason to exist.
PreparePathRegexp_Hit2 allocs: thefmt.Sprintfcache key,not the LRU itself. A future micro-opt (pooled
strings.Builder)could remove these; not worth doing now.
Why the Copy() removal works
cache.getRegexppreviously returnedv.(*regexp.Regexp).Copy()fordefensive concurrency. As of Go 1.12 (Feb 2019), the stdlib documents
*regexp.Regexpas safe for concurrent reads via itsMatch*,Find*,ReplaceAll*, etc. methods without copying. OnlyLongest()mutates —and Tyk's
Regexp.Longest()wrapper is dead code (a logic-flippednil-guard that would only invoke the underlying
Longest()on a nilreceiver, panicking).
The race tests in
regexp/cache_test.go::TestCache_ConcurrentReads_SharedRegexhammer 14 read methods × 200 goroutines × 200 iterations on a shared
cached regex and pass cleanly under
go test -race.Related Issue
Motivation and Context
How This Has Been Tested
Screenshots (if appropriate)
Types of changes
Checklist
Ticket Details
TT-17049
Generated at: 2026-05-28 12:22:16