Skip to content

Fix log buffering race by removing the rule selector cache - #7803

Merged
evgenyfedorov2 merged 6 commits into
mainfrom
evgenyfedorov2-fix-log-buffering-rule-selector-race
Oct 5, 2026
Merged

evgenyfedorov2 merged 6 commits into
mainfrom
evgenyfedorov2-fix-log-buffering-rule-selector-race

Conversation

@evgenyfedorov2

@evgenyfedorov2 evgenyfedorov2 commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

LogBufferingFilterRuleSelector is a singleton shared by every global and per-request log buffer. Under concurrent load, per-request log buffering throws InvalidOperationException: Collection was modified; enumeration operation may not execute from Select(), which makes the feature unusable in production.

Fixes: #7383

Root cause

The selector cached rule candidates per (LogLevel, EventId) in pooled List<LogBufferingFilterRule> instances. InvalidateCache() returned those lists to the pool, which clears them, while concurrent Select() calls could still be iterating them.

InvalidateCache() runs every time a request ends: the scoped IncomingRequestLogBufferHolder disposes each per-category IncomingRequestLogBuffer, and each of them invalidated the shared cache. Measured with TestServer, the cache was cleared once per request per category, so it rarely helped:

Log calls per request Requests Concurrency Cache misses
2 different calls 100 1 100%
2 different calls 2000 64 62%
20 identical calls (issue repro) 100 1 5%
20 identical calls (issue repro) 2000 64 18%

Even a cache hit allocated a closure (56 B per log call), and the cache key ignored the logger category, so one category could get candidates computed from another category's rules.

Fix

Remove the cache. Select() is now a static, single pass over the category's rules (the LogBufferingFilterRule[] each buffer already computes), with the same matching order and result as before. With no shared state there is nothing to race on, nothing to invalidate, and no per-call allocation. InvalidateCache(), the selector's DI registration and constructor parameters, and the Dispose methods that only existed to invalidate the cache are removed.

Benchmarks (BenchmarkDotNet MediumRun, .NET 10, Apple M3 Max), per log call:

Scenario main this PR
Warm cache, 1 rule 9.6 ns, 56 B 2.2 ns, 0 B
Warm cache, 10 rules 16.4 ns, 56 B 15.5 ns, 0 B
10 log calls then request end, 1 rule 32.7 ns, 186 B 2.5 ns, 0 B
10 log calls then request end, 10 rules 45.1 ns, 186 B 14.6 ns, 0 B

Alternatives that keep a cache were measured as well. With 8 threads sharing one selector, an immutable-array cache and a pooled-list cache behind a ReaderWriterLockSlim cost 80-280 ns per call from contention on cache clears, versus 1-8 ns without a cache. The only scenario where a cache won was a warm cache with 10 rules (7.4 ns vs 15.5 ns); with request-end invalidation it lost again (29.3 ns vs 14.6 ns). Taking an array instead of IList<T> matters: the IList<T> version took 51 ns in the 10-rule request scenario.

Testing

  • New SelectsRightRule_WhenCategoriesHaveDifferentRules: the same scenario returned the other category's rule with the cached implementation.
  • Microsoft.Extensions.Telemetry.Tests (341 tests) and Microsoft.AspNetCore.Diagnostics.Middleware.Tests (188 tests) pass on net9.0 and net10.0.
Microsoft Reviewers: Open in CodeFlow

โ€ฆCache

LogBufferingFilterRuleSelector is a singleton shared by all global and
per-request log buffers. Its cache stored pooled List<T> instances that
InvalidateCache() returned to the pool (clearing them) while concurrent
Select() calls could still be enumerating them. Under load this threw
"Collection was modified; enumeration operation may not execute",
NullReferenceException, or silently produced wrong buffering decisions.

Cache immutable arrays instead, so InvalidateCache() only needs to clear
the dictionary and readers can safely finish enumerating evicted values.

Fixes #7383

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Evgenii Fedorov and others added 3 commits October 5, 2026 16:38
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the timing-based Parallel.For test with a single-threaded test that
invalidates the cache while Select() iterates over the cached rules, and
assert explicitly that no exception is thrown.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@dotnet-comment-bot

Copy link
Copy Markdown
Collaborator

โ€ผ๏ธ Found issues โ€ผ๏ธ

Project Coverage Type Expected Actual
Microsoft.Extensions.Diagnostics.Testing Line 99 98.65 ๐Ÿ”ป
Microsoft.Extensions.Telemetry Line 93 92.63 ๐Ÿ”ป
Microsoft.Extensions.AI.OpenAI Line 75 68.32 ๐Ÿ”ป
Microsoft.Extensions.AI.OpenAI Branch 75 57.59 ๐Ÿ”ป
Microsoft.Extensions.DataIngestion.MarkItDown Line 75 4.46 ๐Ÿ”ป
Microsoft.Extensions.DataIngestion.MarkItDown Branch 75 0 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring Line 99 96.03 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring Branch 99 92.76 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring.Kubernetes Line 99 97.73 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Dns Line 75 71.61 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery Line 75 67.36 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery Branch 75 71.43 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Abstractions Line 75 42.11 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Abstractions Branch 75 42.86 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Yarp Line 75 73.85 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Yarp Branch 75 70 ๐Ÿ”ป
Microsoft.Extensions.VectorData.Abstractions Line 75 37.39 ๐Ÿ”ป
Microsoft.Extensions.VectorData.Abstractions Branch 75 22.73 ๐Ÿ”ป

๐ŸŽ‰ Good job! The coverage increased ๐ŸŽ‰
Update MinCodeCoverage in the project files.

Project Expected Actual
Microsoft.Extensions.Http.Diagnostics 94 95
Microsoft.Gen.BuildMetadata 97 100
Microsoft.Gen.MetadataExtractor 57 73
Microsoft.Gen.MetricsReports 67 69
Microsoft.Extensions.AI.Abstractions 82 86
Microsoft.Extensions.AI.Evaluation.NLP 0 78
Microsoft.Extensions.Caching.Hybrid 82 85
Microsoft.Extensions.DataIngestion 75 89
Microsoft.Extensions.DataIngestion.Markdig 75 90
Microsoft.Extensions.Http.Resilience 97 100

Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1623526&view=codecoverage-tab

The cache was cleared at the end of every request, so it rarely produced
hits, while it allocated on every Select() call, contended under
concurrent load, and ignored the logger category in its key.

Select() is now a static, single pass over the category's rules array:
no shared state to race on, nothing to invalidate, and no allocations.
InvalidateCache(), the selector's DI registration and constructor
parameters, and the Dispose methods that only invalidated the cache are
removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@evgenyfedorov2 evgenyfedorov2 changed the title Fix race between LogBufferingFilterRuleSelector.Select and InvalidateCache Fix log buffering race by removing the rule selector cache Oct 5, 2026
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@evgenyfedorov2
evgenyfedorov2 marked this pull request as ready for review October 5, 2026 18:53
@evgenyfedorov2
evgenyfedorov2 requested a review from a team as a code owner October 5, 2026 18:53
Copilot AI balanced review requested due to automatic review settings October 5, 2026 18:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

๐ŸŸข Approval recommended

The shared mutable state is fully removed, callers are consistently updated, and targeted regression coverage verifies category isolation.

Review effort: Balanced
Findings: None

What changed in this PR

Removes the shared rule-selector cache to eliminate log-buffering races and incorrect cross-category rule reuse.

Changes:

  • Replaces cached rule selection with stateless array iteration.
  • Removes obsolete selector injection, invalidation, and disposal paths.
  • Adds regression coverage for category-specific rules.
File Description
test/โ€‹Libraries/โ€‹Microsoft.Extensions.Telemetry.Tests/โ€‹Buffering/โ€‹LogBufferingFilterRuleTests.cs Updates selector calls and adds regression coverage.
src/โ€‹Libraries/โ€‹Microsoft.Extensions.Telemetry/โ€‹Buffering/โ€‹LogBufferingFilterRuleSelector.cs Makes rule selection static and cache-free.
src/โ€‹Libraries/โ€‹Microsoft.Extensions.Telemetry/โ€‹Buffering/โ€‹GlobalLogBufferManager.cs Removes selector dependency.
src/โ€‹Libraries/โ€‹Microsoft.Extensions.Telemetry/โ€‹Buffering/โ€‹GlobalBufferLoggingBuilderExtensions.cs Removes obsolete DI registration.
src/โ€‹Libraries/โ€‹Microsoft.Extensions.Telemetry/โ€‹Buffering/โ€‹GlobalBuffer.cs Uses stateless selection and removes invalidation.
src/โ€‹Libraries/โ€‹Microsoft.AspNetCore.Diagnostics.Middleware/โ€‹Buffering/โ€‹PerRequestLogBufferManager.cs Removes selector propagation.
src/โ€‹Libraries/โ€‹Microsoft.AspNetCore.Diagnostics.Middleware/โ€‹Buffering/โ€‹IncomingRequestLogBufferHolder.cs Removes cache-only disposal behavior.
src/โ€‹Libraries/โ€‹Microsoft.AspNetCore.Diagnostics.Middleware/โ€‹Buffering/โ€‹IncomingRequestLogBuffer.cs Uses static selection and drops cache-only disposal.

๐Ÿ’ก Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@dotnet-comment-bot

Copy link
Copy Markdown
Collaborator

โ€ผ๏ธ Found issues โ€ผ๏ธ

Project Coverage Type Expected Actual
Microsoft.Extensions.Diagnostics.Testing Line 99 98.65 ๐Ÿ”ป
Microsoft.Extensions.Telemetry Line 93 92.53 ๐Ÿ”ป
Microsoft.Extensions.AI.OpenAI Line 75 68.32 ๐Ÿ”ป
Microsoft.Extensions.AI.OpenAI Branch 75 57.59 ๐Ÿ”ป
Microsoft.Extensions.DataIngestion.MarkItDown Line 75 4.46 ๐Ÿ”ป
Microsoft.Extensions.DataIngestion.MarkItDown Branch 75 0 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring Line 99 96.03 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring Branch 99 92.76 ๐Ÿ”ป
Microsoft.Extensions.Diagnostics.ResourceMonitoring.Kubernetes Line 99 97.73 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Dns Line 75 69.93 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery Line 75 67.21 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery Branch 75 71.43 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Abstractions Line 75 42.11 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Abstractions Branch 75 42.86 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Yarp Line 75 73.85 ๐Ÿ”ป
Microsoft.Extensions.ServiceDiscovery.Yarp Branch 75 70 ๐Ÿ”ป
Microsoft.Extensions.VectorData.Abstractions Line 75 37.39 ๐Ÿ”ป
Microsoft.Extensions.VectorData.Abstractions Branch 75 22.73 ๐Ÿ”ป

๐ŸŽ‰ Good job! The coverage increased ๐ŸŽ‰
Update MinCodeCoverage in the project files.

Project Expected Actual
Microsoft.Extensions.Http.Diagnostics 94 95
Microsoft.Gen.BuildMetadata 97 100
Microsoft.Gen.MetadataExtractor 57 73
Microsoft.Gen.MetricsReports 67 69
Microsoft.Extensions.AI.Abstractions 82 86
Microsoft.Extensions.AI.Evaluation.NLP 0 78
Microsoft.Extensions.Caching.Hybrid 82 85
Microsoft.Extensions.DataIngestion 75 89
Microsoft.Extensions.DataIngestion.Markdig 75 90
Microsoft.Extensions.Http.Resilience 97 100

Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1623814&view=codecoverage-tab

@evgenyfedorov2
evgenyfedorov2 merged commit 0c15512 into main Oct 5, 2026
6 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

4 participants