Output Caching: MemoryCacheEntryOptions.Size omits retained cache-key bytes from SizeLimit accounting
- 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
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