Skip to content

Replace PhpClassReflectionExtension::evictPrivateSymbols() with a shared LRU over the member caches#5986

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
php-class-reflection-extension-member-cache-lru
Jul 3, 2026
Merged

Replace PhpClassReflectionExtension::evictPrivateSymbols() with a shared LRU over the member caches#5986
ondrejmirtes merged 2 commits into
2.2.xfrom
php-class-reflection-extension-member-cache-lru

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Problem

PhpClassReflectionExtension's four member caches (methodsIncludingAnnotations, nativeMethods, propertiesIncludingAnnotations, nativeProperties) are unbounded except for evictPrivateSymbols(), which drops only private members of the just-analysed class. Public/protected member reflections accumulate for the whole process.

Instrumented on a large project (ShipMonk backend), these caches were the single largest analysis-growth bucket: 334 MB of a 675 MB per-process growth at 2,358 analysed files (3,710 + 6,258 + 2,501 + 2,073 first-level keys; each entry a full PhpMethodReflection/PhpPropertyReflection graph).

The private-only eviction also silently misses the composite class-scope cache keys used by getProperty() — it compares keys with !== against the plain class cache key, so scoped private property entries were never evicted.

Change

One insertion-ordered LRU over the first-level cache keys, shared by all four maps:

  • every cache access moves the key to the most-recently-used position,
  • inserting beyond 2048 keys evicts the least recently used key's entries from all four maps at once,
  • evictPrivateSymbols() (and its per-class full-map scan after every analysed class, plus the delegation from ClassReflection::evictPrivateSymbols()) is removed.

Misses are pure recomputation, so eviction cannot change results.

Measured (2,358-file project, single process)

  • error output byte-identical (776 errors), across three independent runs
  • no measurable time cost (99 s → 99 s; LRU bookkeeping is one unset+set per access, and the old eviction's full map scan per analysed class is gone)
  • member-cache keys at end of run: 14,542 → 3,608
  • end-of-run memory: −32 MB on this project — honest caveat: much of the evicted graphs' weight survives via other holders of the same ClassReflections (MemoizingReflectionProvider, DependencyResolver::$classDependencies, rule state), so the standalone win is bounded. The value is replacing an incomplete, quadratic-ish eviction with a general bounded cache — a prerequisite for the eviction to cascade once the sibling holders are bounded too.

The 2048 cap could be promoted to a cache.* parameter like nameScopeMapMemoryCacheCountMax if desired.

Tests: ClassReflectionTest, TypesAssignedToPropertiesRuleTest, CallMethodsRuleTest pass; phpcs clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF

ondrejmirtes and others added 2 commits July 3, 2026 13:07
…red LRU over the member caches

The four member caches (methodsIncludingAnnotations, nativeMethods,
propertiesIncludingAnnotations, nativeProperties) were unbounded except for
private members of the just-analysed class. Public/protected member
reflections accumulated for the whole process — measured as the single
largest analysis-growth bucket on a large project (334 MB of a 675 MB
per-process growth at 2,358 files). The private-only eviction also silently
missed the composite `class-scope` cache keys used by getProperty(), because
it compared keys with strict equality against the plain class cache key.

Replace it with a single insertion-ordered LRU over the first-level cache
keys, shared by all four maps: every cache access moves the key to the
most-recently-used position; inserting beyond 2048 keys evicts the least
recently used key's entries from all four maps at once. Misses are pure
recomputation, so eviction cannot change results — verified byte-identical
error output on a 2,358-file project, with no measurable time cost (LRU
bookkeeping is one unset+set per access, and the previous eviction's full
map scan per analysed class is gone).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NrTvR9j1mW2NNNC8RkuTsF
Replaces the MEMBER_CACHE_KEYS_MAX constant with a parameter alongside the
other cache limits; 0 means unlimited, same as cache.phpStormStubsNodesCountMax.

The 2048 default is confirmed by a sweep on a 2,358-file project (8 workers,
result cache cleared, error output byte-identical across all runs): unlimited
costs 9.02 GB summed worker peaks, any limit in 512-2048 lands on the same
8.8 GB plateau, and 128-256 give the savings back through evict-recompute
churn. No limit-dependent time cost: wall and user CPU varied together across
limits (machine scheduling noise), never with the limit. Self-analysis is flat
in both time and memory at every limit down to 64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8cNW3DZDEJjeBK2WXqgP7
@ondrejmirtes ondrejmirtes merged commit 709654e into 2.2.x Jul 3, 2026
669 of 671 checks passed
@ondrejmirtes ondrejmirtes deleted the php-class-reflection-extension-member-cache-lru branch July 3, 2026 18:39
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