From 76ddf6eafbb37bb4e372627d667ffc9f6598fe66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 21:31:28 +0200 Subject: [PATCH 1/2] fix: use metadata signatures for warm kernel caches --- CHANGELOG.md | 3 + docs/cache-request-cost.md | 30 +++++++++ docs/dependency-injection.md | 21 ++++--- src/Discovery/BundleDiscovery.php | 3 +- src/Discovery/KernelPackageManifestCache.php | 12 +--- src/Kernel/ContainerCacheManager.php | 3 - src/Kernel/ContainerResourceFingerprinter.php | 12 +--- src/Kernel/ResourceFingerprint.php | 33 ++++++++++ tests/Fixtures/cache-hash-probe.php | 8 +++ tests/Fixtures/cache-request.php | 2 +- .../Kernel/CacheRequestCompatibilityTest.php | 42 ++++++++++++- tests/Kernel/KernelTestCase.php | 1 + .../Kernel/RuntimeCacheCompatibilityTest.php | 7 ++- tools/benchmark-cache-requests.php | 61 +++++++++++++++++++ 14 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 docs/cache-request-cost.md create mode 100644 src/Kernel/ResourceFingerprint.php create mode 100644 tools/benchmark-cache-requests.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ae9847d..eda2bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ where applicable. ## Unreleased +- Default mutable cache validation to mtime/size metadata; keep content hashing as an explicit `SYMPRESS_KERNEL_CONTENT_HASHES=1` mode. Warm container and discovery reads retain OPcache entries. +- Verify upgrade from group-writable caches without executing or modifying prior metadata, discovery or container artifacts. + ### Added - Bridge `App::enableDebug()` and `App::disableDebug()` to an optional profiler service. diff --git a/docs/cache-request-cost.md b/docs/cache-request-cost.md new file mode 100644 index 0000000..a0ea216 --- /dev/null +++ b/docs/cache-request-cost.md @@ -0,0 +1,30 @@ +# Cache request cost + +Mutable caches use mtime/size signatures by default. Set +`SYMPRESS_KERNEL_CONTENT_HASHES=1` when detecting same-size edits with preserved +timestamps is required. Switching modes changes the cache identity. Immutable +production snapshots use `SYMPRESS_KERNEL_IMMUTABLE_CACHE=1` and a unique build ID; +every changed release input requires a changed build ID. + +Run `php tools/benchmark-cache-requests.php` to measure 40 warm HTTP requests +through PHP's built-in server with OPcache enabled. The fixture has one 1 MiB PHP +configuration file and runs the real container cache hit path. The probe counts +content-hash bytes and OPcache invalidations. The server and fixture are removed +afterward; no customer application or database is touched. + +The before/after run on PHP 8.5.9, 2026-10-02, produced: + +| Measurement | Previous content default | Metadata default | +| --- | ---: | ---: | +| Warm hits | 40/40 | 40/40 | +| Hashed bytes per request | 1,288,952 | 0 | +| OPcache invalidations per request | 1 | 0 | +| Cache selection p50 / p95, ms | 1.364 / 1.648 | 0.942 / 1.006 | +| HTTP p50 / p95, ms | 1.876 / 2.518 | 1.379 / 1.439 | + +These are local fixture timings, not WordPress response-time guarantees. Input +size, filesystems, PHP configuration and concurrent load affect latency. +`CacheRequestCompatibilityTest` verifies zero warm hashing/invalidation, timestamp +invalidation, explicit content-mode detection and safe migration of group-writable +cache artifacts. The metadata mode intentionally cannot detect an edit preserving +both size and mtime; clear the cache for such edits. diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index 52bbf58..23602e2 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -990,7 +990,7 @@ wp console container:dump --format=yaml ## Runtime Cache Both debug and production use compiled-container cache hits. A hit loads recorded -config paths from `meta.php`, validates consumed input content, and takes a shared +config paths from `meta.php`, validates consumed input signatures, and takes a shared reader lock; it does not execute config loaders or rebuild discovery globs. Only misses take the exclusive writer lock and recheck after waiting. Writer metadata and container publication are atomic, private files use mode 0600, and newly @@ -1009,13 +1009,15 @@ existing PHP dumps. Symlinked directories are rejected; private fallback directo owner-checked and never admit group/world writes. Keep configured cache parents trusted and outside DOCUMENT_ROOT/WP_CONTENT_DIR. -Normal validation hashes root/package Composer metadata and consumed config contents, -including imported files, so equal size/timestamp edits invalidate correctly. Top-level -configuration is no longer hashed again in the deployment identity. Missing recorded -config digests force a rebuild. This reduces work on each real warm request while -retaining content checks across requests; a stat-only digest cache could miss same-timestamp edits. -Recorded config directory entries detect newly added/deleted config files. Debug -also hashes source contents. Production source changes require a new +Normal validation compares modification time and size for root/package Composer +metadata and consumed configuration, including imports. Warm requests do not read +these file contents and do not invalidate OPcache entries; publication invalidates +only files that were written. Missing recorded config signatures force a rebuild. +Edits preserving both size and modification time require a cache clear or explicit +`SYMPRESS_KERNEL_CONTENT_HASHES=1`. That opt-in mode hashes consumed inputs and debug +source files, detects those edits, and has a distinct cache identity. Recorded +config directory entries detect newly added/deleted config files. Debug tracks source +metadata by default. Production source changes require a new `SYMPRESS_KERNEL_BUILD_ID`, a cache clear, or optional `SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES=1` (mtime/size source checks). @@ -1196,7 +1198,8 @@ Useful debug points: Compiler-provided `DirectoryResource` inputs (for example external ORM entity paths) participate in mutable freshness checks and generated class identity. -Recursive PHP resources hash contents, additions and removals in stable order; +Recursive PHP resources compare file metadata, additions and removals in stable order +(or content hashes with `SYMPRESS_KERNEL_CONTENT_HASHES=1`); child symlink files/directories are not followed outside the configured root. A cold build may compile twice when compiler-added resources change its initial identity. The second builder is fresh, uses the final `kernel.container_class`, diff --git a/src/Discovery/BundleDiscovery.php b/src/Discovery/BundleDiscovery.php index 4cda907..503e196 100644 --- a/src/Discovery/BundleDiscovery.php +++ b/src/Discovery/BundleDiscovery.php @@ -8,6 +8,7 @@ use SymPress\Kernel\Bundle\BundleInterface; use SymPress\Kernel\Bundle\BundleMetadata; use SymPress\Kernel\Bundle\BundleRegistry; +use SymPress\Kernel\Kernel\ResourceFingerprint; use SymPress\Kernel\Resolver\ActivePackageResolver; use Symfony\Component\DependencyInjection\Kernel\RequiredBundle; @@ -526,7 +527,7 @@ private function composerMetadata(string $composerFile): array 'type' => $original['type'] ?? '', 'extra' => ['kernel' => array_intersect_key($kernel, array_flip(['bundle', 'entry', 'requires']))], ]; - $this->metadataHashes[$composerFile] = hash('sha256', $contents); + $this->metadataHashes[$composerFile] = ResourceFingerprint::file($composerFile); return $this->metadata[$composerFile]; } diff --git a/src/Discovery/KernelPackageManifestCache.php b/src/Discovery/KernelPackageManifestCache.php index d1f96de..5bc0ebf 100644 --- a/src/Discovery/KernelPackageManifestCache.php +++ b/src/Discovery/KernelPackageManifestCache.php @@ -7,6 +7,7 @@ use Composer\InstalledVersions; use SymPress\Kernel\Kernel\CachePath; use SymPress\Kernel\Kernel\ContainerResourceFingerprinter; +use SymPress\Kernel\Kernel\ResourceFingerprint; final class KernelPackageManifestCache { @@ -42,9 +43,6 @@ public function read(): ?array return null; } try { - if (function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } $metadata = require $file; } catch (\ParseError) { return null; @@ -176,6 +174,7 @@ private function fingerprint(): string (string) $this->projectDir, (string) $this->environment, implode(',', $this->packagePrefixes), + ResourceFingerprint::contentHashes() ? 'content' : 'metadata', $this->immutable() ? 'immutable' : $this->fileFingerprint($this->rootComposerFile()), $this->immutable() ? $this->buildIdentity() : $this->fileFingerprint($this->rootComposerLockFile()), $this->immutable() ? 'immutable' : $this->fileFingerprint($this->installedPackagesFile()), @@ -219,11 +218,6 @@ private function installedPackagesFile(): string private function fileFingerprint(string $file): string { - if ($file === '' || !is_file($file)) { - return 'missing'; - } - - clearstatcache(true, $file); - return hash_file('sha256', $file) ?: 'unreadable'; + return ResourceFingerprint::file($file); } } diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php index df5b738..355a5df 100644 --- a/src/Kernel/ContainerCacheManager.php +++ b/src/Kernel/ContainerCacheManager.php @@ -212,9 +212,6 @@ private function readMetadata(string $file): ?array return null; } try { - if (function_exists('opcache_invalidate')) { - opcache_invalidate($file, true); - } $metadata = require $file; return is_array($metadata) ? $metadata : null; } catch (\ParseError) { diff --git a/src/Kernel/ContainerResourceFingerprinter.php b/src/Kernel/ContainerResourceFingerprinter.php index f62ae05..dfca0b5 100644 --- a/src/Kernel/ContainerResourceFingerprinter.php +++ b/src/Kernel/ContainerResourceFingerprinter.php @@ -28,6 +28,7 @@ public function fingerprint(BundleRegistry $bundles, array $configFiles): string $this->projectDir, $this->environment, (string) (int) $this->debug, + ResourceFingerprint::contentHashes() ? 'content' : 'metadata', $this->deploymentFingerprint(), $this->kernelFingerprint(), @@ -42,7 +43,7 @@ public function fingerprint(BundleRegistry $bundles, array $configFiles): string $parts[] = $this->fileFingerprint($bundle->composerFile()); } - // Consumed config contents (including imports) are validated separately by + // Consumed config signatures (including imports) are validated separately by // configResourcesAreFresh and participate in the compiled generation key. // Keep only discovery identity here to avoid reading each config twice on hits. array_push($parts, ...$configFiles); @@ -336,14 +337,7 @@ private function configResourceValue(string $key): string private function fileFingerprint(string $file): string { - if (!is_file($file)) { - return 'missing'; - } - - clearstatcache(true, $file); - $hash = hash_file('sha256', $file); - - return is_string($hash) ? $hash : 'unreadable'; + return ResourceFingerprint::file($file); } private function deploymentFingerprint(): string diff --git a/src/Kernel/ResourceFingerprint.php b/src/Kernel/ResourceFingerprint.php new file mode 100644 index 0000000..658a191 --- /dev/null +++ b/src/Kernel/ResourceFingerprint.php @@ -0,0 +1,33 @@ +configureContainer($container->builder(), $container, $bundles); $kernel->createRuntimeContainer($container, $bundles, $files); } -echo json_encode(['hit' => $hit, 'hashes' => $GLOBALS['cache_request_hashes'] ?? [], 'bytes' => $GLOBALS['cache_request_bytes'] ?? 0, 'milliseconds' => (hrtime(true) - $start) / 1e6, 'value' => $container->getParameter('imported.value')], JSON_THROW_ON_ERROR); +echo json_encode(['hit' => $hit, 'hashes' => $GLOBALS['cache_request_hashes'] ?? [], 'bytes' => $GLOBALS['cache_request_bytes'] ?? 0, 'invalidations' => $GLOBALS['cache_request_invalidations'] ?? 0, 'milliseconds' => (hrtime(true) - $start) / 1e6, 'value' => $container->getParameter('imported.value')], JSON_THROW_ON_ERROR); diff --git a/tests/Kernel/CacheRequestCompatibilityTest.php b/tests/Kernel/CacheRequestCompatibilityTest.php index bcfe779..8cce326 100644 --- a/tests/Kernel/CacheRequestCompatibilityTest.php +++ b/tests/Kernel/CacheRequestCompatibilityTest.php @@ -6,6 +6,7 @@ use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\Kernel\CachePath; +use SymPress\Kernel\Discovery\KernelPackageManifestCache; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; @@ -18,9 +19,16 @@ public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void mkdir($directory, 0775, true); chmod($directory, 0775); file_put_contents($directory . '/meta.php', 'read()); + $discovery->write(['sympress/kernel']); + self::assertSame(['sympress/kernel'], $discovery->read()); $kernel = $this->kernel($project); $container = $kernel->createContainer(); $files = $kernel->configureContainer($container->builder(), $container, new BundleRegistry()); @@ -28,10 +36,12 @@ public function testExistingGroupWritableCacheUsesFreshPrivateStorage(): void self::assertTrue($kernel->tryUseRuntimeContainer($kernel->createContainer(), new BundleRegistry())); self::assertSame(0700, fileperms($cache) & 0777); self::assertSame(0775, fileperms($directory) & 0777); + self::assertSame($old, hash_file('sha256', $directory . '/meta.php')); + self::assertSame(0600, fileperms($cache . '/discovery-packages.php') & 0777); (new Filesystem())->remove($cache); } - public function testSuccessiveRequestsHashConfigOnceAndDetectSameTimestampReplacement(): void + public function testSuccessiveRequestsUseMetadataWithoutHashesOrOpcacheInvalidation(): void { $project = $this->tmpPath('request-hashes'); mkdir($project . '/config', 0700, true); @@ -46,13 +56,41 @@ public function testSuccessiveRequestsHashConfigOnceAndDetectSameTimestampReplac foreach (range(1, 2) as $unused) { $request = $run(); self::assertTrue($request['hit']); - self::assertSame(1, $request['hashes'][$file]); + self::assertSame([], $request['hashes']); + self::assertSame(0, $request['bytes']); + self::assertSame(0, $request['invalidations']); } $stamp = filemtime($file); $this->writeConfig($file . '.new', 'other'); touch($file . '.new', $stamp); rename($file . '.new', $file); $request = $run(); + self::assertTrue($request['hit']); + self::assertSame('first', $request['value']); + touch($file, $stamp + 1); + $request = $run(); + self::assertFalse($request['hit']); + self::assertSame('other', $request['value']); + } + + public function testExplicitContentModeDetectsSameSizeSameTimestampReplacement(): void + { + $project = $this->tmpPath('request-content-hashes'); + mkdir($project . '/config', 0700, true); + $file = $project . '/config/services.php'; + $this->writeConfig($file, 'first'); + $run = static function () use ($project): array { + $process = new Process([PHP_BINARY, dirname(__DIR__) . '/Fixtures/cache-request.php', $project], env: ['SYMPRESS_KERNEL_CONTENT_HASHES' => '1']); + $process->mustRun(); + + return json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + }; + self::assertFalse($run()['hit']); + self::assertTrue($run()['hit']); + $stamp = filemtime($file); + $this->writeConfig($file, 'other'); + touch($file, $stamp); + $request = $run(); self::assertFalse($request['hit']); self::assertSame('other', $request['value']); } diff --git a/tests/Kernel/KernelTestCase.php b/tests/Kernel/KernelTestCase.php index 8e11b82..383a42e 100644 --- a/tests/Kernel/KernelTestCase.php +++ b/tests/Kernel/KernelTestCase.php @@ -36,6 +36,7 @@ protected function tearDown(): void $_ENV['DB_PASSWORD'], $_ENV['WP_AUTH_KEY'], $_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'], + $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'], $GLOBALS['kernel_test_do_actions'], $GLOBALS['kernel_test_filter_values'], ); diff --git a/tests/Kernel/RuntimeCacheCompatibilityTest.php b/tests/Kernel/RuntimeCacheCompatibilityTest.php index 74cd00d..8de546d 100644 --- a/tests/Kernel/RuntimeCacheCompatibilityTest.php +++ b/tests/Kernel/RuntimeCacheCompatibilityTest.php @@ -7,6 +7,7 @@ use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\Kernel\CachePath; use SymPress\Kernel\Kernel\ContainerResourceFingerprinter; +use SymPress\Kernel\Kernel\ResourceFingerprint; use SymPress\Kernel\Kernel\SiteKernel; use SymPress\Kernel\Kernel\KernelConfigurationResolver; use SymPress\Kernel\Tests\Support\TestSiteConfig; @@ -28,6 +29,7 @@ final class RuntimeCacheCompatibilityTest extends KernelTestCase { public function testRecursiveDirectoryResourcesTrackContentsAdditionRemovalAndIgnoreSymlinkEscapes(): void { + $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1'; $root = $this->tmpPath('external-resources'); mkdir($root, 0700, true); $file = $root . '/mapped.php'; @@ -170,6 +172,7 @@ public function testRuntimeCacheInvalidatesWhenImportedConfigFileChanges(): void public function testDebugHitSkipsConfigExecutionAndDetectsSameTimestampEdits(): void { + $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1'; $project = $this->tmpPath('debug-cache'); mkdir($project . '/config', 0700, true); $file = $project . '/config/services.php'; @@ -196,6 +199,7 @@ public function testDebugHitSkipsConfigExecutionAndDetectsSameTimestampEdits(): public function testImportedSameTimestampEditRebuildsActualParameter(): void { + $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1'; $project = $this->tmpPath('import-content'); mkdir($project . '/config', 0700, true); $this->writeImportingConfig($project . '/config/services.php', 'first'); @@ -453,6 +457,7 @@ public function testRuntimeCompilationPreservesSourceServiceLocatorGraphForLint( public function testPackageDescriptorsReuseAcrossRequestsAndInvalidateConsumedContent(): void { + $_SERVER['SYMPRESS_KERNEL_CONTENT_HASHES'] = '1'; $project = $this->tmpPath('descriptor-cache'); mkdir($project, 0700); file_put_contents($project . '/composer.json', '{}'); @@ -461,7 +466,7 @@ public function testPackageDescriptorsReuseAcrossRequestsAndInvalidateConsumedCo $stamp = filemtime($input); $cache = new KernelPackageManifestCache($project, 'test', ['sympress/']); $descriptors = [$input => ['name' => 'sympress/one', 'type' => 'library', 'extra' => ['kernel' => ['bundle' => 'Example']]]]; - $cache->write(['sympress/one'], $descriptors, [$input => hash_file('sha256', $input)]); + $cache->write(['sympress/one'], $descriptors, [$input => ResourceFingerprint::file($input)]); $nextRequest = new KernelPackageManifestCache($project, 'test', ['sympress/']); self::assertSame(['sympress/one'], $nextRequest->read()); self::assertSame($descriptors, $nextRequest->metadata()); diff --git a/tools/benchmark-cache-requests.php b/tools/benchmark-cache-requests.php new file mode 100644 index 0000000..6f99e22 --- /dev/null +++ b/tools/benchmark-cache-requests.php @@ -0,0 +1,61 @@ +remove($root); +mkdir($root . '/config', 0700, true); +$config = 'parameters()->set("imported.value", "benchmark"); };'; +file_put_contents($root . '/config/services.php', $config . "\n/*" . str_repeat('x', 1024 * 1024) . '*/'); +$router = $root . '/router.php'; +file_put_contents($router, 'start(); +try { + for ($attempt = 0; $attempt < 100; ++$attempt) { + if (str_contains($server->getErrorOutput(), 'Development Server')) { + break; + } + usleep(10000); + } + $requests = []; + for ($index = 0; $index < 41; ++$index) { + $start = hrtime(true); + $content = file_get_contents('http://' . $address . '/'); + if ($content === false) { + throw new RuntimeException('Benchmark HTTP request failed.'); + } + $result = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + $result['http_ms'] = (hrtime(true) - $start) / 1e6; + $requests[] = $result; + } + $warm = array_slice($requests, 1); + $latencies = array_column($warm, 'milliseconds'); + $http = array_column($warm, 'http_ms'); + sort($latencies); + sort($http); + echo json_encode([ + 'php' => PHP_VERSION, + 'requests' => count($warm), + 'warm_hits' => count(array_filter($warm, static fn (array $request): bool => $request['hit'])), + 'hashed_bytes_per_request' => array_sum(array_column($warm, 'bytes')) / count($warm), + 'invalidations_per_request' => array_sum(array_column($warm, 'invalidations')) / count($warm), + 'cache_ms_p50' => $latencies[19], + 'cache_ms_p95' => $latencies[37], + 'http_ms_p50' => $http[19], + 'http_ms_p95' => $http[37], + ], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n"; +} finally { + $server->stop(); + (new Filesystem())->remove($root); +} From 375e113e687717216526922d2d0acef06d43d6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Brian=20Sch=C3=A4ffner?= Date: Fri, 2 Oct 2026 21:54:32 +0200 Subject: [PATCH 2/2] docs: prepare reviewed patch release notes --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda2bbb..9bb879f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ where applicable. - Validate consumed configuration content once per mutable warm request while preserving same-timestamp edits, imported resources and the explicit immutable build-ID policy. - Boot existing installations with group-writable compiled-cache directories through a fresh private generation without executing or chmod-adopting prior PHP cache artifacts. -## Unreleased +## 1.1.3 — 2026-10-02 - Default mutable cache validation to mtime/size metadata; keep content hashing as an explicit `SYMPRESS_KERNEL_CONTENT_HASHES=1` mode. Warm container and discovery reads retain OPcache entries. - Verify upgrade from group-writable caches without executing or modifying prior metadata, discovery or container artifacts.