Skip to content

perf(metadata): reuse the cached resource metadata collection - #8493

Merged
soyuka merged 1 commit into
api-platform:4.3from
soyuka:perf/resource-metadata-collection-reuse
Sep 2, 2026
Merged

soyuka merged 1 commit into
api-platform:4.3from
soyuka:perf/resource-metadata-collection-reuse

Conversation

@soyuka

@soyuka soyuka commented Sep 2, 2026

Copy link
Copy Markdown
Member

CachedResourceMetadataCollectionFactory::create() returns a new ResourceMetadataCollection on 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

ResourceMetadataCollection memoizes operation lookups in private array $operationCache, but because the instance is discarded after each create(), that memo never survives. getOperation() re-runs its full scan every time — getIterator(), then a generator per ApiResource (Operations::getIterator() is a \Generator), then a loop over every operation.

Instrumenting one GET /dummies?itemsPerPage=30 JSON-LD request (30 items with IRI relations, ~150 resources registered) confirmed the memo is structurally dead:

before after
create() calls / request 92 92
create() ms / request 0.136 0.059
getOperation() calls / request 91 91
getOperation() ms / request 0.192 0.053
memo hits / full scans 0 / 2275 2275 / 0
combined % of request wall time 1.25% 0.40%

The change

Memoize the collection in the factory instead of rebuilding it, which is what CachedTrait::getCached() already does for CachedPropertyMetadataFactory, CachedPropertyNameCollectionFactory and CachedResourceNameCollectionFactory. 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 \ArrayObject and is mutable. Why that is safe here:

  • The mutable inner graph was already shared. The previous code stored (array) $resourceMetadataCollection in its local cache and rebuilt from it, i.e. the same ApiResource and Operations instances were handed to every caller already. Only the outer \ArrayObject was fresh. This PR additionally shares that outer container — it does not widen exposure to the nested mutable state.
  • The cached factory decorates at priority -10, the lowest, so it is the outermost decorator (resource.php#L158-L159).
  • All 34 $resourceMetadataCollection[$i] = … sites across 25 files in src/ 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.
  • No consumer in 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 shared ResourceMetadataCollection to 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

  • testItReturnsTheSameInstanceOnEveryCall pins the new contract.
  • testTheOperationLookupIsNotRepeatedOnEveryCall uses a CountingApiResource that counts getOperations() calls, and fails without this change.
  • testItCallsTheDecoratedFactoryOnce guards 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 ResourceMetadataCollection genuinely immutable would turn the audit above into a guarantee, but it is a larger question: sealing the outer \ArrayObject is not sufficient, because Operations is itself mutable and NotExposedOperationResourceMetadataCollectionFactory relies on mutating it by aliasing rather than through offsetSet. Worth its own issue.

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
soyuka force-pushed the perf/resource-metadata-collection-reuse branch 2 times, most recently from 6d09b96 to 4844c7c Compare September 2, 2026 09:24
@soyuka
soyuka changed the base branch from main to 4.3 September 2, 2026 09:24
@soyuka
soyuka merged commit c27509b into api-platform:4.3 Sep 2, 2026
1 check passed
@soyuka
soyuka deleted the perf/resource-metadata-collection-reuse branch September 2, 2026 09:35
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.

1 participant