From e359fbdf0f90f5ec252fb5551fffa7e6e30bf917 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 19 Aug 2026 13:33:02 +0200 Subject: [PATCH 1/2] Use LruCache for the other three least-recently-used caches #5928 introduced LruCache for the parser's file-contents memo. Three more caches were doing the same bookkeeping by hand, so they now use it as well: - PhpClassReflectionExtension's shared member-cache order, where set() returning the evicted keys is what lets the four member maps drop the same key - FileTypeMapper's name-scope map memo, whose manual entry counter count() replaces - UsefulTypeAliasResolver's resolved local type aliases FileTypeMapper's cache treated a configured maximum of 0 as "keep one entry" rather than "no limit" as the others do, because its eviction loop ran before the insertion. That is preserved as-is; normalising it would change behaviour for anyone who set the parameter to 0. FileTypeMapper's resolvedPhpDocBlockCache is deliberately left alone: it does not touch entries on a hit, so it evicts in insertion order rather than by use, and moving it to LruCache would change its eviction policy rather than just its shape. Behaviour unchanged on an identical analysis: same errors reported, and the same cache footprint - 1168 name-scope map misses, 2 local type alias misses and 910 member-cache evictions before and after. Co-Authored-By: Claude Opus 5 (1M context) --- .../Php/PhpClassReflectionExtension.php | 30 +++++++--------- src/Type/FileTypeMapper.php | 35 +++++++------------ src/Type/UsefulTypeAliasResolver.php | 26 ++++++-------- 3 files changed, 34 insertions(+), 57 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 59bbf690301..053d7c8aa80 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -16,6 +16,7 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Internal\LruCache; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; @@ -62,7 +63,6 @@ use PHPStan\Type\TypehintHelper; use PHPStan\Type\UnionType; use function array_key_exists; -use function array_key_first; use function array_keys; use function array_map; use function array_slice; @@ -77,8 +77,8 @@ final class PhpClassReflectionExtension { - /** @var array shared LRU over the member cache keys below; first entry = least recently used */ - private array $memberCacheOrder = []; + /** @var LruCache shared LRU over the member cache keys below */ + private LruCache $memberCacheOrder; /** @var PhpPropertyReflection[][] */ private array $propertiesIncludingAnnotations = []; @@ -121,6 +121,7 @@ public function __construct( private int $memberCacheKeysMax, ) { + $this->memberCacheOrder = new LruCache($this->memberCacheKeysMax); } /** @@ -135,25 +136,18 @@ public function __construct( */ private function touchMemberCacheKey(string $cacheKey): void { - if (isset($this->memberCacheOrder[$cacheKey])) { - unset($this->memberCacheOrder[$cacheKey]); - $this->memberCacheOrder[$cacheKey] = true; + if ($this->memberCacheOrder->get($cacheKey) !== null) { return; } - $this->memberCacheOrder[$cacheKey] = true; - if ($this->memberCacheKeysMax === 0 || count($this->memberCacheOrder) <= $this->memberCacheKeysMax) { - return; + foreach ($this->memberCacheOrder->set($cacheKey, true, 0) as $evictKey) { + unset( + $this->methodsIncludingAnnotations[$evictKey], + $this->nativeMethods[$evictKey], + $this->propertiesIncludingAnnotations[$evictKey], + $this->nativeProperties[$evictKey], + ); } - - $evictKey = array_key_first($this->memberCacheOrder); - unset( - $this->memberCacheOrder[$evictKey], - $this->methodsIncludingAnnotations[$evictKey], - $this->nativeMethods[$evictKey], - $this->propertiesIncludingAnnotations[$evictKey], - $this->nativeProperties[$evictKey], - ); } public function hasProperty(ClassReflection $classReflection, string $propertyName): bool diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index e57f329f53c..e2763332024 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -15,6 +15,7 @@ use PHPStan\File\FileContentHasher; use PHPStan\File\FileHelper; use PHPStan\Internal\ComposerHelper; +use PHPStan\Internal\LruCache; use PHPStan\Parser\Parser; use PHPStan\PhpDoc\NameScopeAlreadyBeingCreatedException; use PHPStan\PhpDoc\PhpDocNodeResolver; @@ -58,10 +59,8 @@ final class FileTypeMapper private const SKIP_NODE = 1; private const POP_TYPE_MAP_STACK = 2; - /** @var array}> */ - private array $memoryCache = []; - - private int $memoryCacheCount = 0; + /** @var LruCache}> */ + private LruCache $memoryCache; /** @var array */ private array $inProcess = []; @@ -90,6 +89,10 @@ public function __construct( private int $nameScopeMapMemoryCacheCountMax, ) { + // 0 kept one entry here rather than meaning "no limit" as it does for the other bounded + // caches: the eviction loop ran before the insertion, emptying the cache and then putting + // a single entry back. Preserved rather than normalised - see the PR description. + $this->memoryCache = new LruCache($this->nameScopeMapMemoryCacheCountMax === 0 ? 1 : $this->nameScopeMapMemoryCacheCountMax); } /** @api */ @@ -336,13 +339,8 @@ public function getNameScope( */ private function getNameScopeMap(string $fileName): array { - if (isset($this->memoryCache[$fileName])) { - // LRU: move the freshly-accessed entry to the end so eviction drops - // genuinely cold files, not hot dependencies inserted early on. - $cachedEntry = $this->memoryCache[$fileName]; - unset($this->memoryCache[$fileName]); - $this->memoryCache[$fileName] = $cachedEntry; - + $cachedEntry = $this->memoryCache->get($fileName); + if ($cachedEntry !== null) { return $cachedEntry; } @@ -364,19 +362,10 @@ private function getNameScopeMap(string $fileName): array } else { [$nameScopeMap] = $cached; } - while ($this->memoryCacheCount >= $this->nameScopeMapMemoryCacheCountMax) { - $oldestKey = array_key_first($this->memoryCache); - if ($oldestKey === null) { - break; - } - unset($this->memoryCache[$oldestKey]); - $this->memoryCacheCount--; - } - - $this->memoryCache[$fileName] = [$nameScopeMap]; - $this->memoryCacheCount++; + $entry = [$nameScopeMap]; + $this->memoryCache->set($fileName, $entry, 0); - return $this->memoryCache[$fileName]; + return $entry; } /** diff --git a/src/Type/UsefulTypeAliasResolver.php b/src/Type/UsefulTypeAliasResolver.php index ed745d63606..71a34f8f924 100644 --- a/src/Type/UsefulTypeAliasResolver.php +++ b/src/Type/UsefulTypeAliasResolver.php @@ -5,13 +5,12 @@ use PHPStan\Analyser\NameScope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Internal\LruCache; use PHPStan\PhpDoc\TypeNodeResolver; use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use function array_key_exists; -use function array_key_first; -use function count; use function sprintf; #[AutowiredService(as: TypeAliasResolver::class)] @@ -21,8 +20,8 @@ final class UsefulTypeAliasResolver implements TypeAliasResolver /** @var array */ private array $resolvedGlobalTypeAliases = []; - /** @var array LRU; first entry = least recently used */ - private array $resolvedLocalTypeAliases = []; + /** @var LruCache */ + private LruCache $resolvedLocalTypeAliases; /** @var array */ private array $resolvingClassTypeAliases = []; @@ -43,6 +42,7 @@ public function __construct( private int $resolvedLocalTypeAliasesCountMax, ) { + $this->resolvedLocalTypeAliases = new LruCache($this->resolvedLocalTypeAliasesCountMax); } public function hasTypeAlias(string $aliasName, ?string $classNameScope): bool @@ -84,12 +84,9 @@ private function resolveLocalTypeAlias(string $aliasName, NameScope $nameScope): $aliasNameInClassScope = $className . '::' . $aliasName; - if (array_key_exists($aliasNameInClassScope, $this->resolvedLocalTypeAliases)) { - // LRU: move to the most-recently-used position - $resolvedAliasType = $this->resolvedLocalTypeAliases[$aliasNameInClassScope]; - unset($this->resolvedLocalTypeAliases[$aliasNameInClassScope]); - - return $this->resolvedLocalTypeAliases[$aliasNameInClassScope] = $resolvedAliasType; + $resolvedAliasType = $this->resolvedLocalTypeAliases->get($aliasNameInClassScope); + if ($resolvedAliasType !== null) { + return $resolvedAliasType; } // prevent infinite recursion @@ -127,12 +124,9 @@ private function resolveLocalTypeAlias(string $aliasName, NameScope $nameScope): $resolvedAliasType = new CircularTypeAliasErrorType(); } - $this->resolvedLocalTypeAliases[$aliasNameInClassScope] = $resolvedAliasType; - if ($this->resolvedLocalTypeAliasesCountMax !== 0 && count($this->resolvedLocalTypeAliases) > $this->resolvedLocalTypeAliasesCountMax) { - // resolved alias types transitively pin ClassReflections and their whole - // reflection trees — evict the least recently used - unset($this->resolvedLocalTypeAliases[array_key_first($this->resolvedLocalTypeAliases)]); - } + // resolved alias types transitively pin ClassReflections and their whole + // reflection trees, so the cache is bounded and evicts the least recently used + $this->resolvedLocalTypeAliases->set($aliasNameInClassScope, $resolvedAliasType, 0); unset($this->inProcess[$aliasNameInClassScope]); return $resolvedAliasType; From bc70f8e53c2854ba028cb52074094fd7af634f33 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 19 Aug 2026 13:50:04 +0200 Subject: [PATCH 2/2] Stop keeping the cache limits as properties The four limits are only read where the LruCache is constructed, so holding them as promoted properties keeps per-instance state nobody reads and suggests the classes consult them later. Plain constructor parameters instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/Parser/CachedParser.php | 4 ++-- src/Reflection/Php/PhpClassReflectionExtension.php | 4 ++-- src/Type/FileTypeMapper.php | 4 ++-- src/Type/UsefulTypeAliasResolver.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Parser/CachedParser.php b/src/Parser/CachedParser.php index 82193bd4846..818c6a399b1 100644 --- a/src/Parser/CachedParser.php +++ b/src/Parser/CachedParser.php @@ -59,12 +59,12 @@ final class CachedParser implements Parser public function __construct( private Parser $originalParser, private int $cachedNodesByStringCountMax, - private int $cachedSourceBytesMax = self::CACHED_SOURCE_BYTES_DEFAULT_LIMIT, + int $cachedSourceBytesMax = self::CACHED_SOURCE_BYTES_DEFAULT_LIMIT, ) { $this->cachedNodesByString = new LruCache( $this->cachedNodesByStringCountMax, - $this->cachedSourceBytesMax, + $cachedSourceBytesMax, self::SIZE_EVICTION_FLOOR_LIMIT, ); $this->cachedSourceByFile = new LruCache(maxWeight: self::MEMOIZED_SOURCE_BYTES_LIMIT); diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 053d7c8aa80..3c78aa8624e 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -118,10 +118,10 @@ public function __construct( private bool $inferPrivatePropertyTypeFromConstructor, private PhpVersion $phpVersion, #[AutowiredParameter(ref: '%cache.memberCacheKeysMax%')] - private int $memberCacheKeysMax, + int $memberCacheKeysMax, ) { - $this->memberCacheOrder = new LruCache($this->memberCacheKeysMax); + $this->memberCacheOrder = new LruCache($memberCacheKeysMax); } /** diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index e2763332024..3ccd03e06c0 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -86,13 +86,13 @@ public function __construct( #[AutowiredParameter(ref: '%cache.resolvedPhpDocBlockCacheCountMax%')] private int $resolvedPhpDocBlockCacheCountMax, #[AutowiredParameter(ref: '%cache.nameScopeMapMemoryCacheCountMax%')] - private int $nameScopeMapMemoryCacheCountMax, + int $nameScopeMapMemoryCacheCountMax, ) { // 0 kept one entry here rather than meaning "no limit" as it does for the other bounded // caches: the eviction loop ran before the insertion, emptying the cache and then putting // a single entry back. Preserved rather than normalised - see the PR description. - $this->memoryCache = new LruCache($this->nameScopeMapMemoryCacheCountMax === 0 ? 1 : $this->nameScopeMapMemoryCacheCountMax); + $this->memoryCache = new LruCache($nameScopeMapMemoryCacheCountMax === 0 ? 1 : $nameScopeMapMemoryCacheCountMax); } /** @api */ diff --git a/src/Type/UsefulTypeAliasResolver.php b/src/Type/UsefulTypeAliasResolver.php index 71a34f8f924..d35864b3a1a 100644 --- a/src/Type/UsefulTypeAliasResolver.php +++ b/src/Type/UsefulTypeAliasResolver.php @@ -39,10 +39,10 @@ public function __construct( private TypeNodeResolver $typeNodeResolver, private ReflectionProvider $reflectionProvider, #[AutowiredParameter(ref: '%cache.resolvedLocalTypeAliasesCountMax%')] - private int $resolvedLocalTypeAliasesCountMax, + int $resolvedLocalTypeAliasesCountMax, ) { - $this->resolvedLocalTypeAliases = new LruCache($this->resolvedLocalTypeAliasesCountMax); + $this->resolvedLocalTypeAliases = new LruCache($resolvedLocalTypeAliasesCountMax); } public function hasTypeAlias(string $aliasName, ?string $classNameScope): bool