dotnet / dotnet/aspnetcore

Output Caching: MemoryCacheEntryOptions.Size omits retained cache-key bytes from SizeLimit accounting

Open
#67,911 0 comments 0 reactions 0 assignees View on GitHub
area-middleware feature-output-caching
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 10h
Merged PRs (30d)
281

Description

## Summary

The built-in in-process Output Caching store declares each `MemoryCache` entry's size as only the serialized response byte length, while the entry also retains the completed request-derived cache key. As a result, `OutputCacheOptions.SizeLimit` - documented as a byte budget for cache storage - does not account for a retained, variable-size component of every entry. This is a memory-accounting correctness gap, not a demonstrated denial of service.

## What is wrong

* The size-accounting invariant is incomplete: the declared entry size should approximate the retained variable content of the entry, but it counts only the serialized response value and omits the retained key.
* `MemoryOutputCacheStore` sets `MemoryCacheEntryOptions.Size = value.Length`, where `value` is the serialized response. The completed UTF-16 string key is retained by the backing `MemoryCache` for the lifetime of the entry but contributes nothing to the configured byte budget.
* Because keys vary by URL components by default (method, scheme, host, path, and all query keys/values), entries with small responses and large keys under-report their actual retained footprint most.

## Why it matters (defense in depth)

* Correctness: `OutputCacheOptions.SizeLimit` is presented in bytes and treated by operators as the cache-storage bound. Omitting a retained, variable-size portion of each entry means the tracked size can materially understate the retained key content, so the configured budget is a weaker estimate than intended. This is most visible on memory-constrained hosts (containers, small plans) where `SizeLimit` was sized assuming it reflects real cache content.
* Hardening: charging retained key content strengthens the accounting boundary the option is meant to enforce and reduces the amount of key material that can be retained beyond the configured estimate. It does not claim to be an exact managed-heap ceiling; `MemoryCache` sizes are caller-defined estimates and the runtime does not measure object graphs or trim under memory pressure.

## Affected code

* src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs:104-132 - sets `Size = value.Length`; the retained key is not charged
* src/Middleware/OutputCaching/src/OutputCacheServiceCollectionExtensions.cs:20-40 - wires `OutputCacheOptions.SizeLimit` into the backing `MemoryCache` `SizeLimit`
* src/Middleware/OutputCaching/src/OutputCacheOptions.cs:8-33 - documents `SizeLimit` in bytes as the cache-storage bound
* src/Middleware/OutputCaching/src/OutputCacheKeyProvider.cs:13-330 - materializes the completed string key that the store retains
* src/Middleware/OutputCaching/src/CacheEntryHelpers.cs:8-53 - existing repository convention: string content estimated as `Length * sizeof(char)`, body as byte length

## Recommended fix

* Selected approach: at the built-in memory-store sink, include the completed key's UTF-16 content in the declared entry size using overflow-safe `long` arithmetic:

```csharp
Size = checked(value.LongLength + ((long)key.Length * sizeof(char)))
```

This is computed where both the finalized key and the serialized value are known, so it covers every key producer (default policy plus configured header/route/query/prefix/custom variation) without changing key identity. It matches the existing repository estimation convention (`string.Length * sizeof(char)` for characters, byte length for buffers). It intentionally excludes object headers, dictionary/`CacheEntry` structures, callbacks, allocator overhead, and tag-index metadata; those are workload- and runtime-dependent and outside the option's existing byte-content model.

* Alternatives considered:
* Charge a UTF-8/serialized key representation — rejected: does not match the retained in-memory UTF-16 representation and adds a scan/encoding cost.
* Hash or truncate keys — rejected/prohibited: truncation creates response-selection collisions; hashing changes shipped key identity, diagnostics, and distributed/custom-store semantics.
* Separate per-key length limit — deferred as optional defense in depth: bounds one key but does not correct aggregate accounting; would require API review if public.
* Add a separate aggregate key budget — rejected: duplicates `MemoryCache` admission, replacement, concurrency, and eviction state.
* Documentation-only — rejected: leaves an obvious retained variable component outside the byte estimate.

* Compatibility, migration, and versioning:
* No public API change and no `PublicAPI` baseline change.
* Behavior change: with the same `SizeLimit`, applications with long or high-cardinality keys fit fewer entries and may evict/compact earlier. Requests still succeed uncached when an entry is not admitted. Worth a release-note line.
* In-process entries do not survive restart, so no migration or dual-read path is needed.
* Redis and custom `IOutputCacheStore` implementations are unaffected; `OutputCacheOptions.SizeLimit` already applies only to the built-in memory store.
* Do not increase the default 100 MB limit to compensate; that would partially hide the corrected accounting.

## Acceptance criteria

* [ ] The declared entry size includes the retained key's UTF-16 content in addition to the serialized response length.
* [ ] An entry whose value-plus-key size exceeds `SizeLimit` is not retained, and the originating request still completes successfully (uncached).
* [ ] Accounting is charged by UTF-16 code units (verified for BMP and surrogate-pair keys), not UTF-8 bytes or scalar count.
* [ ] Multiple distinct entries consume aggregate capacity by value-plus-key size; an empty value still charges the key.
* [ ] Equal-key replacement accounts for one logical entry; an oversized replacement removes the prior entry.
* [ ] Tagged entries follow the same size rule and rejected tagged candidates leave no stale tag-index state after callback completion.
* [ ] Size arithmetic is overflow-safe (checked/`long`).
* [ ] Behavior change (fewer entries at a given `SizeLimit` for large keys) is documented in release notes.

Contributor guide

Open the contributing guide

Research direction

Start in src/Middleware/OutputCaching/src/Memory/MemoryOutputCacheStore.cs and trace how the finalized key and serialized value reach MemoryCacheEntryOptions.Size. Read CacheEntryHelpers.cs for the repository's string-size convention, then inspect the related OutputCacheOptions and service-registration files. Done means the documented acceptance criteria hold, including size-limit admission, replacement, tagging, UTF-16 accounting, and overflow-safe arithmetic.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
backend, performance
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.