Skip to content

Keep only the relevant environment variables in the container cache key#5927

Open
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:container-cache-key-relevant-env
Open

Keep only the relevant environment variables in the container cache key#5927
SanderMuller wants to merge 1 commit into
phpstan:2.2.xfrom
SanderMuller:container-cache-key-relevant-env

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

The compiled DI container's cache key includes the entire environment, minus a hand-maintained denylist (#4924, #4925). Any unrelated env var that differs between invocations flips the key, and CI runners inject per-job vars on every run. So the container recompiles on every "warm" run: Nette compiles hundreds of service definitions, writes the ~300 KB container, and re-runs the build-time ValidateIgnoredErrorsExtension over every inline ignore pattern.

This continues the denylist work in #4924 / #4925 from the allowlist side. A denylist is incremental (every volatile var has to be found and added), so instead keep only the env the container actually depends on and drop the rest.

The container depends on the environment in two ways:

  1. %env.X% references in loaded configs, which Nette resolves into the container.
  2. env read at build time by a CompilerExtension. PHPStan does this in exactly one place: FnsrExtension::beforeCompile() reads getenv('PHPSTAN_FNSR') and rewires NodeScopeResolver.

Dropping the whole env unless a config uses %env.*% would silently break PHPSTAN_FNSR: with no %env.*% in config, env leaves the key, so toggling PHPSTAN_FNSR reuses a stale container and the rewiring is ignored. The current whole-env key does track this (the value is in the key), so dropping it would be a regression.

This keeps the vars referenced via %env.NAME% in loaded configs, plus the build-time vars PHPStan reads itself (today only PHPSTAN_FNSR), and drops the rest:

  • Unrelated CI/shell vars no longer flip the key: about 0.15 s (trivial config) / 0.18–0.27 s (Tempest, level 8) of CPU per env-changed warm run, and no per-env bloat of the container cache dir.
  • Toggling PHPSTAN_FNSR still recompiles.
  • Even when a config does use %env.*%, only the referenced var stays in the key, not the whole env (the old key kept everything in that case).
  • Extraction follows Nette's %([\w.-]*)% name grammar, so a ref like %env.MY-VAR% is not missed.

The change is one method plus a const in Configurator.php, and a new test (ContainerCacheKeyEnvGuardTest) that fails if a CompilerExtension reads a build-time env var that is not declared or routed through %env.*. That stops this from regressing later.

One residual case: a CompilerExtension that reads getenv() directly at build time, not through %env.*% and not declared, would still have its var dropped. That is the contract (build-time env that affects the container goes through %env.* or is declared), and the guard test enforces it for PHPStan's own code. The denylist already carries this risk for its 7 vars; this does not extend it to the whole environment.

@staabm this is your area (#4924, #4925). I'd value your read on whether completing the denylist from the allowlist side is the right call here.

The container cache key included the entire environment (minus a hand-maintained
denylist - phpstan#4924, phpstan#4925), so any unrelated env var that differs between invocations
(CI runners inject per-job vars on every run) flipped the key and forced a full
container recompile + ignoreErrors re-validation.

The container only depends on the environment through (a) %env.X% references in
loaded configs and (b) env read at build time by a CompilerExtension (PHPStan reads
only PHPSTAN_FNSR, in FnsrExtension::beforeCompile()). Keep exactly those env vars
in the cache key - the %env.NAME% names (matching Nette's %([\w.-]*)% grammar) plus
self::BUILD_TIME_ENV_VARIABLES - and drop the rest. The container parameters still
carry the full env, so %env.*% resolution is unaffected; this only narrows the key.

ContainerCacheKeyEnvGuardTest enforces that every env var read at build time by a
CompilerExtension is declared, so a future build-time getenv() cannot silently reuse
a stale container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanderMuller SanderMuller force-pushed the container-cache-key-relevant-env branch from 8a335c8 to c373f1f Compare June 24, 2026 16:06
private function relevantEnvVariableNamesForCacheKey(): ?array
{
$names = self::BUILD_TIME_ENV_VARIABLES;
foreach ($this->allConfigFiles as $file) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a great way to do this. I think the tracking of used %env... should be somewhere deeper in the DIC compiler. I'm not entirely sure how to implement it. This would have to be researched where Nette DI actually expands this parameters and whether we can efficiently tap into it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ondrejmirtes shall I apply the suggestion in this comment below, or do you have another direction you want me to explore?

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Agreed, scanning config text for %env.*% is the wrong layer (the dashed-name fragility was a symptom of it).

I tried tapping into Nette's expansion directly first, and it's awkward. Helpers::expand resolves a %env.X% ref by walking the array with is_array() && array_key_exists(), so an access-recording ArrayAccess around env fails the is_array check and, not being a DynamicParameter either, makes expand throw Missing parameter (Helpers.php:80). There's no object hook to record through, and DependencyChecker tracks files and classes, not parameters, so there's nothing there to reuse either.

What does work cleanly is to stop baking env into the container at all: move env from a static parameter to a dynamic one (addDynamicParameters). Then %env.X% takes Nette's own DynamicParameter path (Helpers.php:77) and resolves at runtime, the compiled container no longer depends on env values, and env drops out of the cache key with no scanning. I tested it: toggling an unrelated env var reuses the container, and %env.X% (including tmpDir: %env.X%) still resolves.

The one case it doesn't cover is env read at build time via getenv() instead of %env.*%. PHPStan does that in exactly one place: FnsrExtension::beforeCompile() reads getenv('PHPSTAN_FNSR') and rewires the container, so that variable has to stay in the key explicitly (a one-entry allowlist), otherwise toggling it reuses a stale container. Everything else drops.

That removes the regex entirely. I can push it to this PR if the direction looks right.

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.

2 participants