From 3916032046894c8cab18fb5328310c56b1fcfcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 23 Oct 2026 23:41:19 +0200 Subject: [PATCH 1/5] perf(cache): keep the source manifest out of per-request metadata meta.json carried source_resources, one entry per PHP file of the kernel and every bundle, and was parsed on every request although production never validates that list. The manifest now lives in sources.json next to meta.json and is read only when source validation is enabled. Metadata that still embeds source_resources is accepted unchanged. --- docs/dependency-injection.md | 3 +- src/Kernel/ContainerCacheManager.php | 29 +++++++++++++-- .../Kernel/RuntimeCacheCompatibilityTest.php | 36 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index c765b5b..7d5787a 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -1148,7 +1148,8 @@ Useful debug points: - `kernel.container_configured`, to inspect loaded configuration files. - `kernel.container_ready`, to inspect the runtime container. - `var/cache//kernel/meta.json`, to inspect fingerprint, class, and cache - file metadata. + file metadata; the per-file source manifest lives next to it in `sources.json` + and is read only when source validation is enabled. - The showcase plugin screen for attributes, locators, tags, and lazy services. ## Symfony Learn More Map diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 82b8f1c..3422d00 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -19,6 +19,8 @@ final readonly class ContainerCacheManager { + private const string SOURCES_FILE = 'sources.json'; + public function __construct( private string $cacheDir, private bool $debug, @@ -165,6 +167,11 @@ public function createRuntimeContainer( try { $filesystem->dumpFile($containerFile, $dump); chmod($containerFile, 0600); + // The per-file source manifest is only read when source validation is + // enabled, so keep it out of the metadata parsed on every request. + $sourcesFile = sprintf('%s/%s', $this->cacheDir, self::SOURCES_FILE); + $filesystem->dumpFile($sourcesFile, json_encode(['source_resources' => $sourceResources], JSON_THROW_ON_ERROR)); + chmod($sourcesFile, 0600); $filesystem->dumpFile( $metaFile, json_encode( @@ -174,7 +181,7 @@ public function createRuntimeContainer( 'config_discovery' => $this->fingerprints->discoveryResourceManifest($configDirectories), 'config_resources' => $configResources, 'source_fingerprint' => $sourceFingerprint, - 'source_resources' => $sourceResources, + 'sources' => self::SOURCES_FILE, 'class' => $class, 'file' => basename($containerFile), ], @@ -202,6 +209,24 @@ public function createRuntimeContainer( } } + /** + * Older metadata embedded the manifest; current metadata names the sidecar file. + * + * @param array $metadata + */ + private function sourceResources(array $metadata): mixed + { + if (array_key_exists('source_resources', $metadata)) { + return $metadata['source_resources']; + } + if (($metadata['sources'] ?? null) !== self::SOURCES_FILE) { + return null; + } + $sources = $this->readMetadata(sprintf('%s/%s', $this->cacheDir, self::SOURCES_FILE)); + + return $sources['source_resources'] ?? null; + } + /** @return array|null */ private function readMetadata(string $file): ?array { @@ -333,7 +358,7 @@ private function useCachedRuntimeContainer( if ( $this->fingerprints->shouldValidateCachedSourceResources() - && !$this->fingerprints->sourceResourcesAreFresh($metadata['source_resources'] ?? null) + && !$this->fingerprints->sourceResourcesAreFresh($this->sourceResources($metadata)) ) { return false; } diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index c8450df..f1e16c6 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -135,6 +135,14 @@ public function testRuntimeCacheCanValidateBundleSourceFilesWhenEnabled(): void $container = $kernel->createContainer(); $loaded = $kernel->configureContainer($container->builder(), $container, $registry); $kernel->createRuntimeContainer($container, $registry, $loaded); + $cacheDir = "{$projectDir}/var/cache/test/kernel"; + $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); + // The per-file manifest stays out of the metadata parsed on every request. + self::assertArrayNotHasKey('source_resources', $metadata); + self::assertSame('sources.json', $metadata['sources']); + $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); + self::assertArrayHasKey($sourceFile, $sources['source_resources']); + self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); file_put_contents($sourceFile, 'tryUseRuntimeContainer($staleContainer, $registry)); } + public function testRuntimeCacheStillReadsMetadataWithEmbeddedSourceResources(): void + { + $_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] = '1'; + $projectDir = $this->tmpPath('runtime-embedded-sources-project'); + $bundleDir = $this->tmpPath('runtime-embedded-sources-bundle'); + mkdir("{$bundleDir}/src", 0777, true); + file_put_contents("{$bundleDir}/composer.json", '{}'); + file_put_contents("{$bundleDir}/src/DemoService.php", 'registry($bundleDir); + $kernel = $this->kernel($projectDir); + $container = $kernel->createContainer(); + $kernel->createRuntimeContainer($container, $registry, $kernel->configureContainer($container->builder(), $container, $registry)); + $cacheDir = "{$projectDir}/var/cache/test/kernel"; + $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); + $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); + unset($metadata['sources']); + $metadata['source_resources'] = $sources['source_resources']; + file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); + unlink("{$cacheDir}/sources.json"); + + self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); + + unset($metadata['source_resources']); + file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); + + self::assertFalse($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); + } + public function testRuntimeCacheInvalidatesWhenImportedConfigFileChanges(): void { $projectDir = $this->tmpPath('runtime-import-project'); From 026badea96df9a247897875b87a6747ee08679ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:06:56 +0200 Subject: [PATCH 2/5] test: move the embedded source manifest case into its own class RuntimeCacheCompatibilityTest exceeded the 500-line class limit. --- tests/Kernel/CacheSourceManifestTest.php | 36 +++++++++++++++++++ .../Kernel/RuntimeCacheCompatibilityTest.php | 28 --------------- 2 files changed, 36 insertions(+), 28 deletions(-) create mode 100644 tests/Kernel/CacheSourceManifestTest.php diff --git a/tests/Kernel/CacheSourceManifestTest.php b/tests/Kernel/CacheSourceManifestTest.php new file mode 100644 index 0000000..920f7a6 --- /dev/null +++ b/tests/Kernel/CacheSourceManifestTest.php @@ -0,0 +1,36 @@ +tmpPath('runtime-embedded-sources-project'); + $bundleDir = $this->tmpPath('runtime-embedded-sources-bundle'); + mkdir("{$bundleDir}/src", 0777, true); + file_put_contents("{$bundleDir}/composer.json", '{}'); + file_put_contents("{$bundleDir}/src/DemoService.php", 'registry($bundleDir); + $kernel = $this->kernel($projectDir); + $container = $kernel->createContainer(); + $kernel->createRuntimeContainer($container, $registry, $kernel->configureContainer($container->builder(), $container, $registry)); + $cacheDir = "{$projectDir}/var/cache/test/kernel"; + $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); + $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); + unset($metadata['sources']); + $metadata['source_resources'] = $sources['source_resources']; + file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); + unlink("{$cacheDir}/sources.json"); + + self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); + + unset($metadata['source_resources']); + file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); + + self::assertFalse($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); + } +} diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index f1e16c6..ff67211 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -153,34 +153,6 @@ public function testRuntimeCacheCanValidateBundleSourceFilesWhenEnabled(): void self::assertFalse($staleKernel->tryUseRuntimeContainer($staleContainer, $registry)); } - public function testRuntimeCacheStillReadsMetadataWithEmbeddedSourceResources(): void - { - $_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] = '1'; - $projectDir = $this->tmpPath('runtime-embedded-sources-project'); - $bundleDir = $this->tmpPath('runtime-embedded-sources-bundle'); - mkdir("{$bundleDir}/src", 0777, true); - file_put_contents("{$bundleDir}/composer.json", '{}'); - file_put_contents("{$bundleDir}/src/DemoService.php", 'registry($bundleDir); - $kernel = $this->kernel($projectDir); - $container = $kernel->createContainer(); - $kernel->createRuntimeContainer($container, $registry, $kernel->configureContainer($container->builder(), $container, $registry)); - $cacheDir = "{$projectDir}/var/cache/test/kernel"; - $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); - $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); - unset($metadata['sources']); - $metadata['source_resources'] = $sources['source_resources']; - file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); - unlink("{$cacheDir}/sources.json"); - - self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); - - unset($metadata['source_resources']); - file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); - - self::assertFalse($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); - } - public function testRuntimeCacheInvalidatesWhenImportedConfigFileChanges(): void { $projectDir = $this->tmpPath('runtime-import-project'); From 8c6863701d09a114acda8e54309eda6657296ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:16:21 +0200 Subject: [PATCH 3/5] fix(cache): keep one source manifest per generation A single sources.json could be replaced by a rebuild that fails before it publishes meta.json, so the active generation would be validated against another source set. Name the sidecar by generation key, verify it against the container class, remove it with its container, and skip the read under the immutable policy. --- docs/dependency-injection.md | 4 +-- src/Kernel/ContainerCacheManager.php | 25 +++++++++++++------ tests/Kernel/CacheSourceManifestTest.php | 5 ++-- .../Kernel/RuntimeCacheCompatibilityTest.php | 4 +-- 4 files changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 7d5787a..78aaaa5 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -1148,8 +1148,8 @@ Useful debug points: - `kernel.container_configured`, to inspect loaded configuration files. - `kernel.container_ready`, to inspect the runtime container. - `var/cache//kernel/meta.json`, to inspect fingerprint, class, and cache - file metadata; the per-file source manifest lives next to it in `sources.json` - and is read only when source validation is enabled. + file metadata; the per-file source manifest of each generation lives next to + it in `sources_.json` and is read only when source validation is enabled. - The showcase plugin screen for attributes, locators, tags, and lazy services. ## Symfony Learn More Map diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 3422d00..5950a91 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -19,7 +19,7 @@ final readonly class ContainerCacheManager { - private const string SOURCES_FILE = 'sources.json'; + private const string SOURCES_PATTERN = '/^sources_[a-f0-9]{16}\.json$/D'; public function __construct( private string $cacheDir, @@ -168,8 +168,10 @@ public function createRuntimeContainer( $filesystem->dumpFile($containerFile, $dump); chmod($containerFile, 0600); // The per-file source manifest is only read when source validation is - // enabled, so keep it out of the metadata parsed on every request. - $sourcesFile = sprintf('%s/%s', $this->cacheDir, self::SOURCES_FILE); + // enabled, so keep it out of the metadata parsed on every request. One + // sidecar per generation: a failed rebuild must not change the manifest + // that the still-active generation is validated against. + $sourcesFile = sprintf('%s/sources_%s.json', $this->cacheDir, $cacheKey); $filesystem->dumpFile($sourcesFile, json_encode(['source_resources' => $sourceResources], JSON_THROW_ON_ERROR)); chmod($sourcesFile, 0600); $filesystem->dumpFile( @@ -181,7 +183,7 @@ public function createRuntimeContainer( 'config_discovery' => $this->fingerprints->discoveryResourceManifest($configDirectories), 'config_resources' => $configResources, 'source_fingerprint' => $sourceFingerprint, - 'sources' => self::SOURCES_FILE, + 'sources' => basename($sourcesFile), 'class' => $class, 'file' => basename($containerFile), ], @@ -219,12 +221,17 @@ private function sourceResources(array $metadata): mixed if (array_key_exists('source_resources', $metadata)) { return $metadata['source_resources']; } - if (($metadata['sources'] ?? null) !== self::SOURCES_FILE) { + if ($this->fingerprints->immutable()) { + // The policy accepts any recorded manifest; do not read the sidecar for it. + return ['immutable' => 'immutable']; + } + $sources = $metadata['sources'] ?? null; + if (!is_string($sources) || preg_match(self::SOURCES_PATTERN, $sources) !== 1 || $sources !== 'sources_' . substr((string) $metadata['class'], strlen('KernelContainer_')) . '.json') { return null; } - $sources = $this->readMetadata(sprintf('%s/%s', $this->cacheDir, self::SOURCES_FILE)); + $manifest = $this->readMetadata(sprintf('%s/%s', $this->cacheDir, $sources)); - return $sources['source_resources'] ?? null; + return $manifest['source_resources'] ?? null; } /** @return array|null */ @@ -255,6 +262,10 @@ private function cleanupContainers(string $active): void } unlink($file); + $sources = $this->cacheDir . '/sources_' . substr(basename($file), strlen('container_'), 16) . '.json'; + if (is_file($sources) && !is_link($sources)) { + unlink($sources); + } } } diff --git a/tests/Kernel/CacheSourceManifestTest.php b/tests/Kernel/CacheSourceManifestTest.php index 920f7a6..ab84535 100644 --- a/tests/Kernel/CacheSourceManifestTest.php +++ b/tests/Kernel/CacheSourceManifestTest.php @@ -20,11 +20,12 @@ public function testRuntimeCacheStillReadsMetadataWithEmbeddedSourceResources(): $kernel->createRuntimeContainer($container, $registry, $kernel->configureContainer($container->builder(), $container, $registry)); $cacheDir = "{$projectDir}/var/cache/test/kernel"; $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); - $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); + $sources = json_decode((string) file_get_contents("{$cacheDir}/{$metadata['sources']}"), true, flags: JSON_THROW_ON_ERROR); + $sidecar = $metadata['sources']; unset($metadata['sources']); $metadata['source_resources'] = $sources['source_resources']; file_put_contents("{$cacheDir}/meta.json", json_encode($metadata, JSON_THROW_ON_ERROR)); - unlink("{$cacheDir}/sources.json"); + unlink("{$cacheDir}/{$sidecar}"); self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index ff67211..8086406 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -139,8 +139,8 @@ public function testRuntimeCacheCanValidateBundleSourceFilesWhenEnabled(): void $metadata = json_decode((string) file_get_contents("{$cacheDir}/meta.json"), true, flags: JSON_THROW_ON_ERROR); // The per-file manifest stays out of the metadata parsed on every request. self::assertArrayNotHasKey('source_resources', $metadata); - self::assertSame('sources.json', $metadata['sources']); - $sources = json_decode((string) file_get_contents("{$cacheDir}/sources.json"), true, flags: JSON_THROW_ON_ERROR); + self::assertSame('sources_' . substr($metadata['class'], strlen('KernelContainer_')) . '.json', $metadata['sources']); + $sources = json_decode((string) file_get_contents("{$cacheDir}/{$metadata['sources']}"), true, flags: JSON_THROW_ON_ERROR); self::assertArrayHasKey($sourceFile, $sources['source_resources']); self::assertTrue($this->kernel($projectDir)->tryUseRuntimeContainer($this->kernel($projectDir)->createContainer(), $registry)); From b63bdd31cc00f1a962b2edf3ca4354dd6ed68bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:18:00 +0200 Subject: [PATCH 4/5] fix(cache): validate the sidecar name without casting mixed metadata --- src/Kernel/ContainerCacheManager.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index 5950a91..b78fee5 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -226,7 +226,8 @@ private function sourceResources(array $metadata): mixed return ['immutable' => 'immutable']; } $sources = $metadata['sources'] ?? null; - if (!is_string($sources) || preg_match(self::SOURCES_PATTERN, $sources) !== 1 || $sources !== 'sources_' . substr((string) $metadata['class'], strlen('KernelContainer_')) . '.json') { + $class = $metadata['class'] ?? null; + if (!is_string($sources) || !is_string($class) || preg_match(self::SOURCES_PATTERN, $sources) !== 1 || $sources !== 'sources_' . substr($class, strlen('KernelContainer_')) . '.json') { return null; } $manifest = $this->readMetadata(sprintf('%s/%s', $this->cacheDir, $sources)); From a7741849205a2bd03ca54d26c42d502fd2652ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Sat, 24 Oct 2026 00:20:33 +0200 Subject: [PATCH 5/5] style: early exit in container cleanup --- src/Kernel/ContainerCacheManager.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index b78fee5..9fb9933 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -264,9 +264,11 @@ private function cleanupContainers(string $active): void unlink($file); $sources = $this->cacheDir . '/sources_' . substr(basename($file), strlen('container_'), 16) . '.json'; - if (is_file($sources) && !is_link($sources)) { - unlink($sources); + if (!is_file($sources) || is_link($sources)) { + continue; } + + unlink($sources); } }