From e38699d17425d57c3b5b41443fd8dffa869d68df Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Thu, 3 Sep 2026 17:55:05 +0200 Subject: [PATCH 1/2] Sort the result cache's package dependencies before writing them Every other section of the file is ordered before it is written; this one followed the order the workers happened to finish in, so two identical runs of a 4524-file project produced caches of the same size differing in 9848 lines. A run reading the file back does not care, but it means a result cache cannot be hashed, deduplicated or compared between machines. The packages of a single file need the same treatment: they are collected in the order that file's dependencies are reflected, so a file naming Mailer before Logger records their packages in that order. The e2e fixture makes both observable without depending on a race. Four files are split into two parallel jobs, so the section reaches the main process interleaved and no worker completion order can put it in ascending order by accident; and each file declares the two packages in reverse alphabetical order. Dropping either sort fails the assertion on every run, naming what is out of order. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/e2e-tests.yml | 11 ++ .../.gitignore | 2 + .../assert-sorted.php | 107 ++++++++++++++++++ .../composer.json | 26 +++++ .../logger/composer.json | 1 + .../logger/src/Logger.php | 12 ++ .../mailer/composer.json | 1 + .../mailer/src/Mailer.php | 12 ++ .../phpstan.neon | 12 ++ .../src/F1.php | 23 ++++ .../src/F2.php | 23 ++++ .../src/F3.php | 23 ++++ .../src/F4.php | 23 ++++ .../tmp/.gitignore | 2 + .../ResultCache/ResultCacheManager.php | 13 +++ 15 files changed, 291 insertions(+) create mode 100644 e2e/result-cache-deterministic-order/.gitignore create mode 100644 e2e/result-cache-deterministic-order/assert-sorted.php create mode 100644 e2e/result-cache-deterministic-order/composer.json create mode 100644 e2e/result-cache-deterministic-order/logger/composer.json create mode 100644 e2e/result-cache-deterministic-order/logger/src/Logger.php create mode 100644 e2e/result-cache-deterministic-order/mailer/composer.json create mode 100644 e2e/result-cache-deterministic-order/mailer/src/Mailer.php create mode 100644 e2e/result-cache-deterministic-order/phpstan.neon create mode 100644 e2e/result-cache-deterministic-order/src/F1.php create mode 100644 e2e/result-cache-deterministic-order/src/F2.php create mode 100644 e2e/result-cache-deterministic-order/src/F3.php create mode 100644 e2e/result-cache-deterministic-order/src/F4.php create mode 100644 e2e/result-cache-deterministic-order/tmp/.gitignore diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 99db5336ea1..e1f9425b1bd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1360,6 +1360,17 @@ jobs: echo "$OUTPUT" ../bashunit assert contains 'Composer packages changed (test/logger); re-analysing the files depending on them and the files with errors.' "$OUTPUT" ../bashunit assert contains 'Result cache restored. 1 file will be reanalysed.' "$OUTPUT" + - script: | + cd e2e/result-cache-deterministic-order + composer install + # Nothing in the written file may depend on the order the parallel workers happened to + # finish in: a result cache that is not reproducible cannot be hashed, compared or + # deduplicated between two machines. The fixture splits four files into two jobs, so no + # completion order puts them in ascending order by accident, and assert-sorted.php fails + # if any entry section of the cache is out of key order. + ../../bin/phpstan analyse + OUTPUT=$(php assert-sorted.php tmp/resultCache.php 2>&1) || { echo "$OUTPUT"; exit 1; } + echo "$OUTPUT" - script: | cd e2e/result-cache-path-repository composer install diff --git a/e2e/result-cache-deterministic-order/.gitignore b/e2e/result-cache-deterministic-order/.gitignore new file mode 100644 index 00000000000..de4a392c331 --- /dev/null +++ b/e2e/result-cache-deterministic-order/.gitignore @@ -0,0 +1,2 @@ +/vendor +/composer.lock diff --git a/e2e/result-cache-deterministic-order/assert-sorted.php b/e2e/result-cache-deterministic-order/assert-sorted.php new file mode 100644 index 00000000000..99bcd1dc715 --- /dev/null +++ b/e2e/result-cache-deterministic-order/assert-sorted.php @@ -0,0 +1,107 @@ +')); + exit(1); +} + +$handle = fopen($path, 'r'); +if ($handle === false) { + fwrite(STDERR, sprintf("Cannot open %s.\n", $path)); + exit(1); +} + +fgets($handle); // the PHP prefix line that keeps the file inert when included + +$unsorted = []; +$sections = 0; +$packageLists = []; +while (($header = fgets($handle)) !== false) { + $header = rtrim($header, "\n"); + if ($header === '') { + continue; + } + + $parts = explode(' ', $header, 2); + if (count($parts) !== 2) { + fwrite(STDERR, sprintf("Malformed frame header \"%s\".\n", $header)); + exit(1); + } + + [$name, $size] = $parts; + if (!str_ends_with($name, '*')) { + fread($handle, (int) $size); + + continue; + } + + $name = substr($name, 0, -1); + $keys = []; + for ($i = 0; $i < (int) $size; $i++) { + $length = (int) rtrim((string) fgets($handle), "\n"); + $entry = unserialize((string) fread($handle, $length)); + $key = (string) array_key_first($entry); + $keys[] = $key; + if ($name !== 'packageDependencies') { + continue; + } + + // The packages of one file are collected in the order its dependencies are reflected, + // so their order has to be fixed too, not just the order of the files. Unlike the key + // order, this does not depend on how the scheduler composes jobs, so it still fails + // without the sort even when the whole project runs as a single job. + $packageLists[$key] = $entry[array_key_first($entry)]; + } + + $sections++; + $sorted = $keys; + sort($sorted, SORT_STRING); + if ($keys === $sorted) { + continue; + } + + $unsorted[$name] = $keys; +} + +fclose($handle); + +if ($sections === 0) { + fwrite(STDERR, "The result cache has no entry sections, so nothing was checked.\n"); + exit(1); +} + +if ($packageLists === []) { + fwrite(STDERR, "The result cache recorded no package dependencies, so nothing was checked.\n"); + exit(1); +} + +$problems = []; +foreach ($unsorted as $name => $keys) { + $problems[] = sprintf('Section "%s" is not in key order: %s', $name, implode(', ', array_map('basename', $keys))); +} + +foreach ($packageLists as $file => $packages) { + $sortedPackages = $packages; + sort($sortedPackages, SORT_STRING); + if ($packages === $sortedPackages) { + continue; + } + + $problems[] = sprintf('The packages of %s are not in order: %s', basename($file), implode(', ', $packages)); +} + +if ($problems !== []) { + fwrite(STDERR, implode("\n", $problems) . "\n"); + exit(1); +} + +echo sprintf( + "All %d entry sections of the result cache are in key order, and so are the packages of all %d files that have package dependencies.\n", + $sections, + count($packageLists), +); diff --git a/e2e/result-cache-deterministic-order/composer.json b/e2e/result-cache-deterministic-order/composer.json new file mode 100644 index 00000000000..529d90d62ce --- /dev/null +++ b/e2e/result-cache-deterministic-order/composer.json @@ -0,0 +1,26 @@ +{ + "name": "phpstan/result-cache-deterministic-order-e2e", + "repositories": [ + { + "type": "path", + "url": "./logger", + "options": { + "symlink": false + } + }, + { + "type": "path", + "url": "./mailer", + "options": { + "symlink": false + } + } + ], + "require": { + "test/logger": "*", + "test/mailer": "*" + }, + "autoload": { + "classmap": ["src"] + } +} diff --git a/e2e/result-cache-deterministic-order/logger/composer.json b/e2e/result-cache-deterministic-order/logger/composer.json new file mode 100644 index 00000000000..8b33c945f93 --- /dev/null +++ b/e2e/result-cache-deterministic-order/logger/composer.json @@ -0,0 +1 @@ +{ "name": "test/logger", "version": "1.0.0", "autoload": { "classmap": ["src"] } } diff --git a/e2e/result-cache-deterministic-order/logger/src/Logger.php b/e2e/result-cache-deterministic-order/logger/src/Logger.php new file mode 100644 index 00000000000..fa37b4844fb --- /dev/null +++ b/e2e/result-cache-deterministic-order/logger/src/Logger.php @@ -0,0 +1,12 @@ +mailer->send('hello'); + $this->logger->info('hello'); + } + +} diff --git a/e2e/result-cache-deterministic-order/src/F2.php b/e2e/result-cache-deterministic-order/src/F2.php new file mode 100644 index 00000000000..e4b437d54cf --- /dev/null +++ b/e2e/result-cache-deterministic-order/src/F2.php @@ -0,0 +1,23 @@ +mailer->send('hello'); + $this->logger->info('hello'); + } + +} diff --git a/e2e/result-cache-deterministic-order/src/F3.php b/e2e/result-cache-deterministic-order/src/F3.php new file mode 100644 index 00000000000..ca3ae9ea6c2 --- /dev/null +++ b/e2e/result-cache-deterministic-order/src/F3.php @@ -0,0 +1,23 @@ +mailer->send('hello'); + $this->logger->info('hello'); + } + +} diff --git a/e2e/result-cache-deterministic-order/src/F4.php b/e2e/result-cache-deterministic-order/src/F4.php new file mode 100644 index 00000000000..788145e9ca1 --- /dev/null +++ b/e2e/result-cache-deterministic-order/src/F4.php @@ -0,0 +1,23 @@ +mailer->send('hello'); + $this->logger->info('hello'); + } + +} diff --git a/e2e/result-cache-deterministic-order/tmp/.gitignore b/e2e/result-cache-deterministic-order/tmp/.gitignore new file mode 100644 index 00000000000..125e34294bf --- /dev/null +++ b/e2e/result-cache-deterministic-order/tmp/.gitignore @@ -0,0 +1,2 @@ +* +!.* diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index f43162ea10c..29f8275e2f1 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -2033,6 +2033,19 @@ private function save( ksort($exportedNodes); ksort($collectedDataRuleResults); + // Every other section above is ordered before it is written; this one was not, so its order + // followed the order the workers happened to finish in. Two identical runs of a 4524-file + // project produced 74.9 MB caches of the same size differing in 468652 bytes; with this sort + // they differ in the 2 bytes of lastFullAnalysisTime, which is meant to differ. That costs + // nothing to a run reading the file back, but it means the cache cannot be hashed, compared or + // deduplicated between two machines, and "the same analysis produces the same cache" is worth + // having before anyone ships one as a CI artifact. + ksort($packageDependencies); + foreach ($packageDependencies as $file => $packages) { + sort($packages); + $packageDependencies[$file] = $packages; + } + // The only point where the StubFilesExtensions may run: the analysis is over, bootstrapFiles // have been executed, so the extensions can rely on them. restore() reads these hashes back // instead of running the extensions again. From 72be704bbe8272acf6987818260d45eec22c04c9 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 1 Nov 2026 21:39:50 +0100 Subject: [PATCH 2/2] Remove the comment above the sort and update the e2e check The check now reads the current entry frames. The fixture files reference Logger before Mailer, because that order records test/mailer first on 2.3.x, so the check still fails without the sort of a file's packages. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/e2e-tests.yml | 5 ---- .../assert-sorted.php | 25 ++++++++----------- .../phpstan.neon | 4 +-- .../src/F1.php | 7 +++--- .../src/F2.php | 7 +++--- .../src/F3.php | 7 +++--- .../src/F4.php | 7 +++--- .../ResultCache/ResultCacheManager.php | 8 ------ 8 files changed, 24 insertions(+), 46 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e1f9425b1bd..dde586c3c03 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1363,11 +1363,6 @@ jobs: - script: | cd e2e/result-cache-deterministic-order composer install - # Nothing in the written file may depend on the order the parallel workers happened to - # finish in: a result cache that is not reproducible cannot be hashed, compared or - # deduplicated between two machines. The fixture splits four files into two jobs, so no - # completion order puts them in ascending order by accident, and assert-sorted.php fails - # if any entry section of the cache is out of key order. ../../bin/phpstan analyse OUTPUT=$(php assert-sorted.php tmp/resultCache.php 2>&1) || { echo "$OUTPUT"; exit 1; } echo "$OUTPUT" diff --git a/e2e/result-cache-deterministic-order/assert-sorted.php b/e2e/result-cache-deterministic-order/assert-sorted.php index 99bcd1dc715..289805da0d3 100644 --- a/e2e/result-cache-deterministic-order/assert-sorted.php +++ b/e2e/result-cache-deterministic-order/assert-sorted.php @@ -1,8 +1,6 @@ 0) { + fread($handle, (int) $size); + } continue; } @@ -43,24 +44,20 @@ $name = substr($name, 0, -1); $keys = []; for ($i = 0; $i < (int) $size; $i++) { - $length = (int) rtrim((string) fgets($handle), "\n"); - $entry = unserialize((string) fread($handle, $length)); - $key = (string) array_key_first($entry); + [$keyLength, $valueLength] = array_map('intval', explode(' ', rtrim((string) fgets($handle), "\n"), 2)); + $key = unserialize((string) fread($handle, $keyLength)); + $value = unserialize((string) fread($handle, $valueLength)); $keys[] = $key; if ($name !== 'packageDependencies') { continue; } - // The packages of one file are collected in the order its dependencies are reflected, - // so their order has to be fixed too, not just the order of the files. Unlike the key - // order, this does not depend on how the scheduler composes jobs, so it still fails - // without the sort even when the whole project runs as a single job. - $packageLists[$key] = $entry[array_key_first($entry)]; + $packageLists[$key] = $value; } $sections++; $sorted = $keys; - sort($sorted, SORT_STRING); + sort($sorted); if ($keys === $sorted) { continue; } @@ -82,7 +79,7 @@ $problems = []; foreach ($unsorted as $name => $keys) { - $problems[] = sprintf('Section "%s" is not in key order: %s', $name, implode(', ', array_map('basename', $keys))); + $problems[] = sprintf('Section "%s" is not in key order: %s', $name, implode(', ', array_map(static fn (int|string $key): string => basename((string) $key), $keys))); } foreach ($packageLists as $file => $packages) { diff --git a/e2e/result-cache-deterministic-order/phpstan.neon b/e2e/result-cache-deterministic-order/phpstan.neon index 22fa907f5ff..f797b0985db 100644 --- a/e2e/result-cache-deterministic-order/phpstan.neon +++ b/e2e/result-cache-deterministic-order/phpstan.neon @@ -4,9 +4,7 @@ parameters: paths: - src parallel: - # Two jobs of two files each, so the four files reach the main process interleaved - # (F1, F3 in one job, F2, F4 in the other) and no worker completion order can put - # them in ascending order by accident. + # two jobs, so the files reach the main process interleaved jobSize: 2 maximumNumberOfProcesses: 4 minimumNumberOfJobsPerProcess: 1 diff --git a/e2e/result-cache-deterministic-order/src/F1.php b/e2e/result-cache-deterministic-order/src/F1.php index 1b8282eeb0c..0c7d0e81c77 100644 --- a/e2e/result-cache-deterministic-order/src/F1.php +++ b/e2e/result-cache-deterministic-order/src/F1.php @@ -5,19 +5,18 @@ use Test\Logger\Logger; use Test\Mailer\Mailer; -// Mailer is declared before Logger on purpose: the packages of one file are collected in the -// order its dependencies are reflected, so without a sort this file records test/mailer first. +// without the sort, this file records test/mailer before test/logger class F1 { - public function __construct(private Mailer $mailer, private Logger $logger) + public function __construct(private Logger $logger, private Mailer $mailer) { } public function doFoo(): void { - $this->mailer->send('hello'); $this->logger->info('hello'); + $this->mailer->send('hello'); } } diff --git a/e2e/result-cache-deterministic-order/src/F2.php b/e2e/result-cache-deterministic-order/src/F2.php index e4b437d54cf..d9710efa630 100644 --- a/e2e/result-cache-deterministic-order/src/F2.php +++ b/e2e/result-cache-deterministic-order/src/F2.php @@ -5,19 +5,18 @@ use Test\Logger\Logger; use Test\Mailer\Mailer; -// Mailer is declared before Logger on purpose: the packages of one file are collected in the -// order its dependencies are reflected, so without a sort this file records test/mailer first. +// without the sort, this file records test/mailer before test/logger class F2 { - public function __construct(private Mailer $mailer, private Logger $logger) + public function __construct(private Logger $logger, private Mailer $mailer) { } public function doFoo(): void { - $this->mailer->send('hello'); $this->logger->info('hello'); + $this->mailer->send('hello'); } } diff --git a/e2e/result-cache-deterministic-order/src/F3.php b/e2e/result-cache-deterministic-order/src/F3.php index ca3ae9ea6c2..be311c5d8c9 100644 --- a/e2e/result-cache-deterministic-order/src/F3.php +++ b/e2e/result-cache-deterministic-order/src/F3.php @@ -5,19 +5,18 @@ use Test\Logger\Logger; use Test\Mailer\Mailer; -// Mailer is declared before Logger on purpose: the packages of one file are collected in the -// order its dependencies are reflected, so without a sort this file records test/mailer first. +// without the sort, this file records test/mailer before test/logger class F3 { - public function __construct(private Mailer $mailer, private Logger $logger) + public function __construct(private Logger $logger, private Mailer $mailer) { } public function doFoo(): void { - $this->mailer->send('hello'); $this->logger->info('hello'); + $this->mailer->send('hello'); } } diff --git a/e2e/result-cache-deterministic-order/src/F4.php b/e2e/result-cache-deterministic-order/src/F4.php index 788145e9ca1..9af9528da0d 100644 --- a/e2e/result-cache-deterministic-order/src/F4.php +++ b/e2e/result-cache-deterministic-order/src/F4.php @@ -5,19 +5,18 @@ use Test\Logger\Logger; use Test\Mailer\Mailer; -// Mailer is declared before Logger on purpose: the packages of one file are collected in the -// order its dependencies are reflected, so without a sort this file records test/mailer first. +// without the sort, this file records test/mailer before test/logger class F4 { - public function __construct(private Mailer $mailer, private Logger $logger) + public function __construct(private Logger $logger, private Mailer $mailer) { } public function doFoo(): void { - $this->mailer->send('hello'); $this->logger->info('hello'); + $this->mailer->send('hello'); } } diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 29f8275e2f1..40a3310316a 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -2032,14 +2032,6 @@ private function save( ksort($exportedNodes); ksort($collectedDataRuleResults); - - // Every other section above is ordered before it is written; this one was not, so its order - // followed the order the workers happened to finish in. Two identical runs of a 4524-file - // project produced 74.9 MB caches of the same size differing in 468652 bytes; with this sort - // they differ in the 2 bytes of lastFullAnalysisTime, which is meant to differ. That costs - // nothing to a run reading the file back, but it means the cache cannot be hashed, compared or - // deduplicated between two machines, and "the same analysis produces the same cache" is worth - // having before anyone ships one as a CI artifact. ksort($packageDependencies); foreach ($packageDependencies as $file => $packages) { sort($packages);