perf(metadata): reuse the cached resource metadata collection - #8493
Merged
soyuka merged 1 commit intoSep 2, 2026
Merged
Conversation
CachedResourceMetadataCollectionFactory::create() returned a new ResourceMetadataCollection on every call, so the operation lookups memoized in ResourceMetadataCollection::$operationCache never survived. Instrumenting a JSON-LD collection request measured 0 memo hits across 2275 getOperation() calls. Memoize the collection instead, as CachedTrait::getCached() already does for the property and name-collection cached factories. Callers now share one instance; nothing in src/ mutates a collection returned by this factory, and every metadata factory that does mutate decorates inside the cache at a higher priority. Combined cost of create() + getOperation() drops from 1.25% to 0.40% of request wall time.
soyuka
force-pushed
the
perf/resource-metadata-collection-reuse
branch
2 times, most recently
from
September 2, 2026 09:24
6d09b96 to
4844c7c
Compare
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.
CachedResourceMetadataCollectionFactory::create()returns a newResourceMetadataCollectionon every call, even on a local cache hit:https://github.com/api-platform/core/blob/4.3/src/Metadata/Resource/Factory/CachedResourceMetadataCollectionFactory.php#L40-L41
ResourceMetadataCollectionmemoizes operation lookups inprivate array $operationCache, but because the instance is discarded after eachcreate(), that memo never survives.getOperation()re-runs its full scan every time —getIterator(), then a generator perApiResource(Operations::getIterator()is a\Generator), then a loop over every operation.Instrumenting one
GET /dummies?itemsPerPage=30JSON-LD request (30 items with IRI relations, ~150 resources registered) confirmed the memo is structurally dead:create()calls / requestcreate()ms / requestgetOperation()calls / requestgetOperation()ms / requestThe change
Memoize the collection in the factory instead of rebuilding it, which is what
CachedTrait::getCached()already does forCachedPropertyMetadataFactory,CachedPropertyNameCollectionFactoryandCachedResourceNameCollectionFactory. This factory was the only cached metadata factory not doing so.The PSR-6 item still stores the plain
(array)cast — nothing changes about what is serialized into the pool.On sharing the instance
Callers now share one
ResourceMetadataCollection, which unlike the value objects the sibling factories share (ApiProperty,ResourceNameCollection) extends\ArrayObjectand is mutable. Why that is safe here:(array) $resourceMetadataCollectionin its local cache and rebuilt from it, i.e. the sameApiResourceandOperationsinstances were handed to every caller already. Only the outer\ArrayObjectwas fresh. This PR additionally shares that outer container — it does not widen exposure to the nested mutable state.resource.php#L158-L159).$resourceMetadataCollection[$i] = …sites across 25 files insrc/belong to metadata factories decorating at a higher priority, i.e. inside the cache. On a hit none of them run; on a miss they mutate the inner chain's collection before it is cast and stored.src/mutates a collection returned by this factory.Precedent: #8417 (merged into 4.3) made the same trade on the Laravel side —
return $this->localCache[$resourceClass] ??= Cache::store(...)->rememberForever(...)hands one sharedResourceMetadataCollectionto every caller. As of that PR, 4.3 shares instances on the Laravel metadata factory but not the Symfony one; this makes them consistent.For reviewers: the residual risk is third-party code mutating a collection returned by this factory, which would now be visible to other callers. The audit above found zero such sites in
src/, and #8417 already ships the same exposure — but since this targets a patch line, it is worth stating explicitly rather than having someone discover it.Honest scope of the win
~0.22 ms on a 26 ms request. That is below the noise floor of a whole-request benchmark — an interleaved 3-pair A/B could neither confirm nor refute it, and one pair came out marginally slower. Only per-function instrumentation resolves an effect this size, which is what the table above is.
So this is primarily a structural fix — an existing memo that never fired now fires — rather than a headline speedup. The cost does scale with operation count: a cold
getOperation($name)measured 18.6 µs on a 6-operation resource and 53.4 µs on an 18-operation one, against ~1.2 µs for a memo hit.Tests
testItReturnsTheSameInstanceOnEveryCallpins the new contract.testTheOperationLookupIsNotRepeatedOnEveryCalluses aCountingApiResourcethat countsgetOperations()calls, and fails without this change.testItCallsTheDecoratedFactoryOnceguards the existing caching behaviour.New tests use PHPUnit doubles rather than Prophecy.
Not in scope
IriConverter::$localOperationCache(#8472) is left alone. It short-circuits more than the metadata lookup — the resource-class resolution, the 301 remap, the skolem checks and the identifiers-extractor operation — so it is not made redundant by this change.Making
ResourceMetadataCollectiongenuinely immutable would turn the audit above into a guarantee, but it is a larger question: sealing the outer\ArrayObjectis not sufficient, becauseOperationsis itself mutable andNotExposedOperationResourceMetadataCollectionFactoryrelies on mutating it by aliasing rather than throughoffsetSet. Worth its own issue.