Skip to content

Bound the regex and glob caches - #105

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/exciting-albattani-30ndt6-103
Sep 26, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/exciting-albattani-30ndt6-103

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #103

The defect

RegexCache and GlobCache are process-lifetime statics keyed by pattern text, with no eviction, expiry, size cap or Clear/TryRemove anywhere in the file. Every distinct pattern ever passed in stayed cached for the life of the process.

That is unbounded growth in the library's intended use. For a keystroke-driven filter box, "h", "he", "hel", "hell", "hello" are five distinct keys, none of which is ever revisited once the user types the next character — and CacheKey's "i:"/"s:" prefix doubles the key space again. The cache grows with everything ever typed, not with the data being filtered, in tools that run indefinitely.

The change

Both inserts go through one helper instead of TryAdd directly:

private static void AddBounded<TValue>(ConcurrentDictionary<string, TValue> cache, string cacheKey, TValue value)
{
	if (cache.Count >= MaxCacheEntries)
	{
		cache.Clear();
	}

	cache.TryAdd(cacheKey, value);
}

Clear-and-restart rather than LRU, which is the second of the two shapes the issue offers, and the right one here. ConcurrentDictionary keeps no eviction order, so an LRU would mean recording a timestamp or bumping a list node on every cache hit — a write on the exact path the cache exists to keep cheap, and one that would need its own synchronisation to stay correct under the concurrent access the type was chosen for. The bound check instead runs only on a miss, where a regex compile is about to happen anyway, so Count (which does take the dictionary's internal locks) never touches the hot path.

4096 entries, the "generously-sized fixed cap" the issue suggests. Large enough that a realistic session never trips it, small enough to bound the growth; a reset costs one recompile per pattern still in use, on the next miss.

MaxCacheEntries and the two count accessors are internal, which the test project already sees via the existing InternalsVisibleTo in AssemblyInfo.cs. No public API changes.

Tests

test covers
TheRegexCacheStaysBoundedAsDistinctPatternsArrive the defect — 2× the cap in distinct patterns must not leave more than the cap cached
TheGlobCacheStaysBoundedAsDistinctPatternsArrive the same for the glob cache, which the fix must not miss
ACachedPatternStillMatchesAfterTheCacheHasReset the over-correction — a bound that dropped correctness rather than entries

The third is the one that stops this passing for the wrong reason. A "fix" that never cached at all, or that cleared and then failed to re-add, would satisfy both bound assertions; it has to keep answering the same for a pattern that gets evicted and recompiled.

The assertions are <= against the cap rather than an exact count, deliberately: these statics are shared across a suite that runs test methods in parallel, so an exact count would be flaky for reasons unrelated to the bound. <= is the actual invariant and holds regardless of what else is running.

Proved failing without the fix. Removing only the bound from AddBounded — leaving the helper, the cap and the accessors in place so the test project still compiles, since it references them:

failed TheGlobCacheStaysBoundedAsDistinctPatternsArrive (526ms)
  Expected value to be less than or equal to the upper bound.
  The glob cache should be bounded, not grow with every pattern ever seen.
failed TheRegexCacheStaysBoundedAsDistinctPatternsArrive (8s 939ms)
  Expected value to be less than or equal to the upper bound.
  The regex cache should be bounded, not grow with every pattern ever seen.

  total: 87   failed: 2   succeeded: 85

Both fail on cache size — the defect itself. ACachedPatternStillMatchesAfterTheCacheHasReset passes in both configurations, which is correct for a no-regression guard. The 84 pre-existing tests are unaffected in both directions.

Verification

  • dotnet build TextFilter.sln -c Release — succeeded, 0 warnings, 0 errors across net10.0, net9.0, net8.0, netstandard2.1, netstandard2.0 (analyzers run as errors here)
  • dotnet test TextFilter.sln -c Release — 87 total, 87 passed, 0 failed, 0 skipped
  • Same suite with the bound removed — 2 of 87 failed, as above

Run on .NET SDK 10.0.401, Linux.

Interaction with #104

Heads-up for whoever merges second: open PR #104 (Fold case invariantly in regex matching) also touches TextFilter.cs, in DoesMatchRegex — it adds RegexOptions.CultureInvariant to the options a few lines above the insert this change rewrites. Both branch from the same main and neither depends on the other, but they are close enough in the file that a textual conflict on the second merge is likely. The resolution is mechanical: keep #104's three-flag regexOptions and this change's AddBounded(RegexCache, cacheKey, regex);.

That PR explicitly deferred this issue — "it is an eviction-policy decision rather than a correctness fix, and folding it in here would put a bounded-cache design in a one-option bug fix" — which is why this is separate.

Worth noting the two compound in the right direction: #104 makes a cached regex answer the same for every caller, and this bounds how many of them accumulate.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Pup1E96GmvMiRTNMdABat


Generated by Claude Code

Both caches are keyed by pattern text and had no eviction, expiry or size
cap, so every distinct pattern ever passed in stayed for the lifetime of
the process. In the keystroke-driven filter box this library exists for,
every prefix a user types is a distinct key, and the case-sensitivity
prefix on the key doubled the space again - so the caches grew with
everything ever typed rather than with the data being filtered.

Route both inserts through AddBounded, which clears at a 4096-entry cap.
Clear-and-restart rather than LRU because ConcurrentDictionary keeps no
eviction order and tracking one would put a write on every cache hit, the
path the cache exists to keep cheap. The check runs only on a miss, where
a compile is about to happen anyway.

Fixes #103

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Pup1E96GmvMiRTNMdABat
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 846d667 into main Sep 26, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/exciting-albattani-30ndt6-103 branch September 26, 2026 00:53
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.

RegexCache and GlobCache grow unbounded for the lifetime of the process

2 participants