Skip to content

Fixed the validation result cache serving one document's result for another - #639

Open
xperiandri wants to merge 4 commits into
devfrom
validation-cache-hardening
Open

xperiandri wants to merge 4 commits into
devfrom
validation-cache-hardening

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Wrong cached results. The validation result cache keyed its entries by two 32-bit structural hash codes: one of the document and one of the introspected schema. Different documents share these hash codes, for example { f(x: 0) } and { f(x: 4294967297) }. An invalid document could therefore pass validation with the cached result of a valid one.
  • No single flight. Concurrent requests for a document that was not cached yet each ran the validation.
  • Sliding expiration did not slide. A cache hit did not refresh the last use, so every entry expired 30 seconds after it was added, however often it was used.
  • A timer that was never stopped. Every MemoryValidationResultCache started one, including the cache each Executor creates when given none. The timer kept the cache and all its entries alive until the process exited.
  • Hashing the schema per request. Executor computed the structural hash code of the whole introspected schema on every request.

Changes

  • Breaking: ValidationResultKey holds the Document, compared structurally, and the IntrospectionSchema instance, compared by reference. It replaces the DocumentId and SchemaId hash codes.
    • Its hash code only buckets keys. It is computed once per key from every value of the document and mixed with a secret seed chosen per process, so that clients cannot craft many documents sharing one.
    • ExecutionPlan.DocumentId is unchanged.
  • Microsoft.Extensions.Caching.Memory instead of an own cache. MemoryValidationResultCache and the client provider's design-time cache of provided types hold their entries in a MemoryCache. The library's own MemoryCache helper is deleted, and FSharp.Data.GraphQL.Shared now depends on the package, so it also lands in the design-time folder of the client provider.
    • New constructors MemoryValidationResultCache (cache) and MemoryValidationResultCache (cache, slidingExpiration) take the IMemoryCache to use, so that an application can pass one it configures itself, such as a keyed service. It should hold validation results only and have a SizeLimit in bytes.
    • A cache hit refreshes the sliding expiration, and expired entries are removed while the cache is used, not by a timer.
  • Single flight. A memory cache may run the factory of a key once per concurrent request, so MemoryValidationResultCache shares the validation in flight itself and puts only a finished result into the memory cache.
    • Requests share a validation whether or not its result ends up cached.
    • A validation in flight can neither expire nor be evicted, and the sliding expiration of its result counts from when it finished.
    • A validation that throws is run again by the next request.
  • Size limit. The cache now holds the documents of its keys, so every entry declares ValidationResultKey.DocumentSize as its size: the estimated memory of the parsed document in bytes, a fixed amount per node and two bytes per character of names and strings.
    • A cache created without an IMemoryCache owns one limited to MemoryValidationResultCache.DefaultSizeLimit, 16 MiB. The new constructor MemoryValidationResultCache (slidingExpiration, sizeLimit) sets another limit.
    • An entry that does not fit is not cached, and the least recently used entries are evicted to make room for the next ones, a tenth of the limit at a time.
    • A document larger than the whole limit is validated on every request and is not offered to the memory cache at all, so it does not evict the others.
  • Executor reads ISchema.Introspected once, when it is created.
  • Client provider. Its design-time validation cache uses the new key.
  • Docs. The docs of CreateExecutionPlan, of AsyncExecute and docs/execution-pipeline.md no longer call DocumentId unique or suggest caching execution plans by it. A cache keyed by it alone could execute a document that was never validated, with the plan of another.

Not in this PR

IDistributedCache is not supported:

IValidationResultCache stays the extension point for such a cache.

Estimate of the document size

On .NET 10, I measured the memory that parsed documents retain against the estimate:

Document Retained / estimated
100 000 fields 1.13
nesting of 100 1.10
10 000 fragments 1.08
a 1 000 000-character string 1.00
20 000 aliases with directives 0.68
50 000 fields with 2 arguments 0.64

Tests

The tests are xUnit, in the new ValidationCacheTests.fs:

  • documents with colliding hash codes are validated separately, whichever one is cached first; keys with equal hash codes are different;
  • one document is validated separately against each schema that shares the cache;
  • concurrent requests for one key run the validation once; a validation that throws is run again by the next request;
  • concurrent requests share one validation whose result cannot be cached;
  • a validation that outlasts the sliding expiration is still shared, and its result is cached from when it finished;
  • a cache hit refreshes the sliding expiration, on a memory cache with a clock the test sets;
  • entries declare their size to a memory cache with a size limit; a document larger than the limit of an own cache is not cached and does not evict the documents within the limit;
  • an Executor caching in a keyed IMemoryCache of a service provider;
  • the estimate of the document size, including a 100 000-character string;
  • Executor does not read the introspected schema per request.

The three tests of sharing a validation fail when each request runs its own.

Verification:

  • Unit tests: the whole project passes, 786 tests with 5 skipped.
  • FAKE: the full pipeline passes, including the 106 integration tests, which build with the client provider and so load the new package in its design-time folder.

🤖 Generated with Claude Code

xperiandri and others added 3 commits October 3, 2026 13:09
…nother

The validation result cache keyed its entries by the 32-bit structural hash
codes of the document and of the introspected schema. Different documents
share these hash codes (for example `{ f(x: 0) }` and `{ f(x: 4294967297) }`),
so an invalid document could pass validation with the cached result of a
valid one.

- ValidationResultKey now holds the Document, compared structurally, and the
  IntrospectionSchema instance, compared by reference (breaking change). Its
  hash code is only used for bucketing. It is computed once per key from every
  value of the document, mixed with a secret per-process seed, so that clients
  cannot craft many documents with the same hash code.
- MemoryCache stores a Lazy with ExecutionAndPublication in each entry, so
  concurrent requests for a missing key run the validation once and no global
  lock is held while validating. A producer that throws leaves no entry behind.
- A cache hit now refreshes the last use of the entry, so sliding expiration
  actually slides.
- MemoryValidationResultCache now holds the documents of its keys, so it limits
  their total size in AST nodes (100 000 by default, about 10-15 MB). Beyond
  the limit it evicts the least recently used entries, and it does not cache a
  document larger than the whole limit.
- Expired entries are now removed while the cache is used. The previous timer
  was never stopped, so it kept every cache, including the one each Executor
  creates, and all its entries alive until the process exited.
- Executor reads ISchema.Introspected once, when it is created, instead of
  computing the structural hash code of the whole introspected schema on every
  request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Eviction copies the entries with ConcurrentDictionary.ToArray, which takes a consistent snapshot; copying them as a collection threw when another thread added an entry meanwhile, failing the request
* Requests skip caching while another thread evicts and the cache is over its limit, so that additions cannot outrun the eviction
* The size of a key is the estimated memory of its document in bytes, counting the characters of its strings, so that a single large string literal no longer counts as one node; the default limit is 16 MiB
* Documented that execution plans must not be cached by DocumentId alone
* Tests for keys with equal hash codes, the memory estimate, an oversized document not evicting others and eviction under concurrent additions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in the cache tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 10:02

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.

🟡 Changes recommended

Cache-pressure and expiration races still violate single-flight and size-limit guarantees, while execution-plan cache guidance remains incomplete.

4 open findings
What changed in this PR

Fixes validation-cache collisions while improving concurrency, expiration, memory limits, and schema reuse.

Changes:

  • Uses document-aware, schema-specific validation keys.
  • Reworks caching for single-flight production, LRU eviction, and sliding expiration.
  • Adds regression tests and safer execution-plan caching guidance.
File Description
ValidationResultCache.fs Introduces secure keys and bounded validation caching.
MemoryCache.fs Implements lazy production, expiration, and eviction.
Executor.fs Reuses introspection and updates cache keys/docs.
ProvidedTypesHelper.fs Adopts the new validation key.
ValidationCacheTests.fs Adds cache correctness and concurrency tests.
FSharp.Data.GraphQL.Tests.fsproj Includes the new tests.
execution-pipeline.md Warns against DocumentId-only caching.
RELEASE_NOTES.md Documents behavior and API changes.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +88 to +92
let isExpired (now : int64) (entry : CacheEntry<'value>) =
match policy with
| NoExpiration -> false
| AbsoluteExpiration lifetime -> now - entry.Created > lifetime.Ticks
| SlidingExpiration window -> now - entry.LastUsage > window.Ticks
Comment on lines +110 to +117
// ToArray takes a consistent snapshot under the locks of the dictionary; copying it as a collection reads its
// count first and throws when another thread adds an entry before the copy
let byLastUsage = entries.ToArray () |> Array.sortBy _.Value.LastUsage
let mutable index = 0
while Interlocked.Read &totalSize > target && index < byLastUsage.Length do
let pair = byLastUsage[index]
tryRemove pair.Key pair.Value
index <- index + 1
Comment on lines +166 to +169
elif Interlocked.Read &totalSize > sizeLimit && Volatile.Read &maintaining = 1 then
// Another thread is evicting and the cache is still over its limit: entries added meanwhile would only
// outrun the eviction, so the value is not cached until it has caught up
producer ()
Comment on lines +240 to +244
/// <summary>
/// Creates an execution plan for provided GraphQL document AST without
/// executing it. This is useful in cases when you have the same query executed
/// multiple times with different parameters. In that case, query can be used
/// to construct execution plan, which then is cached (using DocumentId as a key) and reused when needed.
/// to construct execution plan, which then is cached and reused when needed.
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 40s ⏱️
  894 tests   889 ✅  5 💤 0 ❌
2 682 runs  2 667 ✅ 15 💤 0 ❌

Results for commit 4ec660a.

♻️ This comment has been updated with latest results.

…ead of an own cache

- `MemoryValidationResultCache` and the design-time cache of provided
  types hold their entries in a `MemoryCache`. The own `MemoryCache`
  helper is deleted, and Shared references the package.
- New constructors of `MemoryValidationResultCache` take the
  `IMemoryCache` to use, such as a keyed service.
- The validation in flight is shared outside the memory cache: it can
  neither expire nor be evicted, requests share it even when its result
  cannot be cached, and the sliding expiration of the result counts
  from when the validation finished.
- The documentation of `AsyncExecute` and of the execution pipeline no
  longer calls `documentId` unique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants