Repository navigation
Fixed the validation result cache serving one document's result for another - #639
Open
xperiandri wants to merge 4 commits into
Open
xperiandri wants to merge 4 commits into
xperiandri wants to merge 4 commits into
Conversation
…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>
Contributor
There was a problem hiding this comment.
🟡 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. |
Test Results 9 files 9 suites 14m 40s ⏱️ 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
{ f(x: 0) }and{ f(x: 4294967297) }. An invalid document could therefore pass validation with the cached result of a valid one.MemoryValidationResultCachestarted one, including the cache eachExecutorcreates when given none. The timer kept the cache and all its entries alive until the process exited.Executorcomputed the structural hash code of the whole introspected schema on every request.Changes
ValidationResultKeyholds theDocument, compared structurally, and theIntrospectionSchemainstance, compared by reference. It replaces theDocumentIdandSchemaIdhash codes.ExecutionPlan.DocumentIdis unchanged.Microsoft.Extensions.Caching.Memoryinstead of an own cache.MemoryValidationResultCacheand the client provider's design-time cache of provided types hold their entries in aMemoryCache. The library's ownMemoryCachehelper is deleted, andFSharp.Data.GraphQL.Sharednow depends on the package, so it also lands in the design-time folder of the client provider.MemoryValidationResultCache (cache)andMemoryValidationResultCache (cache, slidingExpiration)take theIMemoryCacheto use, so that an application can pass one it configures itself, such as a keyed service. It should hold validation results only and have aSizeLimitin bytes.MemoryValidationResultCacheshares the validation in flight itself and puts only a finished result into the memory cache.ValidationResultKey.DocumentSizeas 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.IMemoryCacheowns one limited toMemoryValidationResultCache.DefaultSizeLimit, 16 MiB. The new constructorMemoryValidationResultCache (slidingExpiration, sizeLimit)sets another limit.ExecutorreadsISchema.Introspectedonce, when it is created.CreateExecutionPlan, ofAsyncExecuteanddocs/execution-pipeline.mdno longer callDocumentIdunique 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
IDistributedCacheis not supported:documentIddeterministic SHA-256 string and keepschemaIdas in-memoryintcache key #575 works on the former.IValidationResultCachestays 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:
Tests
The tests are xUnit, in the new
ValidationCacheTests.fs:Executorcaching in a keyedIMemoryCacheof a service provider;Executordoes not read the introspected schema per request.The three tests of sharing a validation fail when each request runs its own.
Verification:
🤖 Generated with Claude Code