Skip to content

Sort the result cache's package dependencies before writing them - #6370

Open
SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:result-cache-deterministic
Open

SanderMuller wants to merge 2 commits into
phpstan:2.3.xfrom
SanderMuller:result-cache-deterministic

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

save() writes the packageDependencies section of the result cache without sorting it. The section follows the order in which the workers finish, so two runs of the same project write different files.

With the sort, two cold runs of a 4524-file project wrote files that differ only in lastFullAnalysisTime. Without it they differed in 615894 bytes. The file can then be compared with diff -a or with a content hash. The sort costs about 0.5 ms per save on that project. I measured these numbers on 2.3.x in September.

The packages of one file get a sort too, because they come in the order in which the file's dependencies are found.

e2e/result-cache-deterministic-order fails without either sort.

🤖 Generated with Claude Code

@SanderMuller
SanderMuller force-pushed the result-cache-deterministic branch from fb89904 to 2252ebd Compare September 4, 2026 21:22
@staabm

staabm commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

I am not sure its worth sorting the results or whether a diff/debug-tool should do it before comparing

@SanderMuller

Copy link
Copy Markdown
Contributor Author

Good question, and it made me go and check which of the reasons actually survive. One of the three I had in mind does not, so here they are with numbers. Two cold runs of the same 4524-file project, 75.3 MB cache, same size every time.

Diffing, which is your point. You are right that a tool could canonicalise first. Worth knowing that plain diff already refuses the framed format from 2.2.13 (Binary files ... differ, about 25k NUL bytes per MB), so this is diff -a territory either way:

diff -a run1 run2 | grep -c '^[<>]'
2.2.x        3932
with sort       2

That is Ondřej's pro-tip from your 2023 post, diffing the cache to find out why it was not reused. It works today, in any shell, without a tool existing. A tool that sorts first would work too, it just has to be written first and then parse every frame and know which sections are order-insensitive.

Content hashing, which a tool cannot retrofit cheaply. Without the sort you cannot content-address the file at all. With it, two runs are identical apart from lastFullAnalysisTime, so a consumer reads that frame's header for its length, skips it and hashes the rest. Ten lines, no further knowledge of the format. I checked both directions: skipping the timestamp, 2.2.x hashes differ and the sorted ones are equal.

Deduplication, which I had in the description and which does not hold up. I measured what an incremental store or transfer would actually rewrite between two runs, and the ordering churn is localised:

              4K blocks   64K blocks
2.2.x         129/18381    10/1149     ~0.5 MB of 75 MB
with sort       1/18381     1/1149     ~0.1 MB

So about half a megabyte per run. Not worth carrying code for, and I will drop that claim from the description.

Cost of the sort, on the real array from that project (4524 files, 2792 package names): median 0.454 ms as it comes out of the run, 0.861 ms if I shuffle it first as a worst case, against a cold run of about 30 s.

So the case is the diff line count and the hashability, not storage. If neither is worth 0.5 ms to you and Ondřej, I am happy to close it, that is a fair reading of the same numbers.

Separately, the description said there was no e2e fixture, which is out of date since I pushed one. Updating it now.

@SanderMuller
SanderMuller force-pushed the result-cache-deterministic branch 3 times, most recently from 540c2c9 to 8f34270 Compare September 25, 2026 21:05
@SanderMuller
SanderMuller changed the base branch from 2.2.x to 2.3.x September 25, 2026 21:05
@SanderMuller
SanderMuller force-pushed the result-cache-deterministic branch from 8f34270 to e293141 Compare September 26, 2026 20:19
@SanderMuller
SanderMuller force-pushed the result-cache-deterministic branch from e293141 to bee7b0d Compare October 5, 2026 16:41
// 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

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 comment is verbose and totally unnecessary

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.

Removed. I also shortened the comments in the e2e fixture.

SanderMuller and others added 2 commits November 1, 2026 21:33
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@SanderMuller
SanderMuller force-pushed the result-cache-deterministic branch from bee7b0d to 72be704 Compare November 1, 2026 20:44
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.

3 participants