Support per-file result cache dependencies - #6300
Draft
jbboehr wants to merge 1 commit into
Draft
Conversation
Allow extensions to associate semantic dependency keys with individual analysed files while calculating hashes in the main process during cache save and restore. This keeps invalidation narrow without making worker results depend on mutable external state. Fail closed for malformed records, stop reading dependency keys once a file is scheduled, and keep persisted hashes out of extension-visible collected data. Document the provider and emission lifecycle contracts and cover the public API and incremental cache behaviour end to end.
jbboehr
force-pushed
the
prototype/result-cache-dependencies
branch
from
August 29, 2026 20:52
5c45e9a to
a88dcc3
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.
Summary
This adds an extension point for attaching extension-defined semantic dependencies to the result of analysing a file.
An extension provides a globally unique, stable provider key. Implementations should normally return
self::class; the explicit key also allows multiple configured instances of the same class to have separate identities. A rule records that a file used an opaque(provider key, dependency key)pair throughResultCacheDependencyCollector. When saving or restoring the result cache, PHPStan asks the registered provider to calculate the hash for that dependency. On restore, PHPStan compares the stored and current hashes and reanalyses the files that emitted a changed dependency.The main pieces are:
ResultCacheDependencyExtension, registered with thephpstan.resultCacheDependencyExtensiontag;ResultCacheDependencyCollector::createData(), emitted throughCollectedDataEmitterfrom a rule;The collector is only a marker for data emitted by rules. It is not registered as a normal collector.
Motivation
ResultCacheMetaExtensionis deliberately global: when its hash changes, the whole result cache is invalidated. That is safe, but expensive for extensions backed by a large metadata source when a file only depends on one small part of it.External file tracking, as proposed in phpstan/phpstan-src#5364, narrows invalidation when the state is naturally represented by separate files. It does not help as much when separate semantic values are stored in one generated object or derived from several source files. Examples include the inferred validation shape of an individual FormRequest or one service/parameter in a Symfony container.
This follows the special-collector direction discussed on that PR, using
CollectedDataEmitterto record per-file dependencies.Invalidation contract
A result-cache dependency represents analysis state consumed directly by a particular analysed file. It does not represent state that changes that file's exported PHP API.
Only files that emitted a changed dependency are reanalysed. Unlike the external-file proposal in phpstan/phpstan-src#5364, their ordinary PHPStan dependants are not added automatically.
This means every file whose analysis result can be affected by the semantic state has to emit its own dependency. If an extension cannot guarantee that—for example because the state has global or indirect effects—it should continue using
ResultCacheMetaExtension.Dependencies must be emitted from
Rule::processNode(). If another extension callback, such as a dynamic return-type extension, consumes the semantic state, a companion rule must recognize that use and emit the same dependency. Other extension callbacks can receive scopes without an active collected-data callback.Dependency keys can come from an older result cache, so
getHash()implementations also need to handle missing or obsolete keys deterministically. The API does not promise a particular call count, order, or process context. Restore can callgetHash()before configuredbootstrapFilesrun, while save can call it afterwards, so the hash cannot depend on application state initialized by those files.Malformed cached records and unknown provider keys reanalyse the affected file. Duplicate provider keys and exceptions thrown by
getHash()abort the analysis instead of being treated as cache misses.Hashes are cache metadata only. A hash supplied on a newly emitted record is discarded and recomputed through the registered provider. Repeated emissions of the same provider/key pair are stored once per file. Hashes are removed before collected data is exposed to
CollectedDataNoderules or returned in the final analysis result, so extensions see the same record shape on cold and warm runs.Prototypes
The phpstan-laravel-validation prototype uses the concrete FormRequest class as the dependency key. Changing one request's validation rules reanalyses that request's consumers without invalidating consumers of unrelated requests.
The opt-in phpstan-symfony prototype uses service IDs and parameter names as dependency keys, while keeping the existing global cache metadata extension enabled by default. It excludes Messenger because that return-type map also depends on handler reflection unavailable during pre-bootstrap restore; the broader safety concern in phpstan/phpstan-symfony#455 still applies. For comparison, phpstan/phpstan-symfony#478 uses the earlier external-file proposal.
Tests
The end-to-end fixture models a dynamic return-type extension backed by per-key configuration types. Coverage includes selective invalidation, malformed and obsolete records, provider isolation, deduplication, untrusted emitted hashes, and cold, warm, and partial cache runs.
I also built the PHAR and ran the external-extension fixture against it:
cd e2e/result-cache-dependency composer install --no-interaction --no-progress ../../tmp/phpstan.phar clear-result-cache ../../tmp/phpstan.phar analyse --no-progress ../../tmp/phpstan.phar analyse --no-progress --fail-without-result-cacheThis verifies that the fixture's rule loads with the public
Scopemethod signature and that the result cache is restored on the second run.I also ran the full PHPUnit suite, self-analysis, PHP_CodeSniffer, and the new end-to-end scenario locally.
Benchmark
I compared a provider emitting no records with one emitting the same shared dependency key from each of 10,000 analysed files. These are 12 alternating warm runs after separate cold-cache primes on PHP 8.3.33; both configurations restored the cache with 0 files reanalysed.
The 10,000 records added about 50 ms, 17.6 MiB of peak RSS, and 3.28 MiB to the cache (about 344 bytes per record). All records share one key, so
getHash()is memoized once; this isolates record storage and restore traversal rather than provider-specific hash cost, high-cardinality keys, or the savings from narrower invalidation.Questions for review
CollectedDataEmitteran acceptable API? Semantic state is often consumed by another extension callback, such as a dynamic return-type extension, so the Rule has to recognize the same use independently.getHash()to be independent ofbootstrapFilesan acceptable limitation, or should providers be able to create a post-bootstrap snapshot that can be checked during cache restore?ResultCacheDependencyExtensionandResultCacheDependencyCollectorreasonable names, given that the collector is used only as an emitted-data marker?