Skip to content

Commit f8a929c

Browse files
ondrejmirtesclaude
andcommitted
Leave the exported nodes in the result cache file until they are needed
The exported nodes are most of the result cache - on Drupal core 135 MB of 160 MB. A run needs the decoded nodes of the few files that changed, and the rest only has to get into the next cache file unchanged. Decoding all of them on restore and serializing them again on save took about half a second of every run that re-analysed anything. The nodes are now stored as an index of files and lengths followed by the serialized nodes back to back. restore() decodes only the ones it compares, and save() copies the bytes of the others from the old file. The old file is closed before the new one is renamed over it, for Windows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdwtgiEHwkF5BTz73jgHQQ
1 parent 79e2e54 commit f8a929c

5 files changed

Lines changed: 526 additions & 14 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ResultCache;
4+
5+
use PHPStan\Dependency\RootExportedNode;
6+
use function array_key_exists;
7+
use function array_keys;
8+
use function fclose;
9+
use function fread;
10+
use function fseek;
11+
use function is_array;
12+
use function is_resource;
13+
use function sprintf;
14+
use function strlen;
15+
use function unserialize;
16+
17+
/**
18+
* The exported nodes a result cache file holds, left in the file until they are asked for.
19+
*
20+
* They are the bulk of the cache - on Drupal core 135 MB of the 160 MB - and a run needs the decoded
21+
* nodes of the few files that changed and nothing else: the rest only has to get into the next
22+
* cache file as it was. Decoding all of them on restore and serializing them again on save was about
23+
* half a second of every run that re-analysed anything. So restore() hands the untouched ones over
24+
* as this, and save() copies their bytes over from the old file.
25+
*
26+
* The file handle is shared by the instances derived from one another, and is closed once save() is
27+
* done copying: on Windows, the old file cannot be replaced while it is open.
28+
*/
29+
final class CachedExportedNodes
30+
{
31+
32+
/**
33+
* @param resource|null $handle
34+
* @param array<string, array{int, positive-int}> $locations file => [offset, length] of its serialized nodes
35+
*/
36+
private function __construct(
37+
private $handle,
38+
private array $locations,
39+
)
40+
{
41+
}
42+
43+
public static function createEmpty(): self
44+
{
45+
return new self(null, []);
46+
}
47+
48+
/**
49+
* @param resource $handle
50+
* @param array<string, array{int, positive-int}> $locations
51+
*/
52+
public static function createFromFile($handle, array $locations): self
53+
{
54+
return new self($handle, $locations);
55+
}
56+
57+
public function has(string $file): bool
58+
{
59+
return array_key_exists($file, $this->locations);
60+
}
61+
62+
/**
63+
* The length of the serialized nodes of a file this holds.
64+
*
65+
* @return positive-int
66+
*/
67+
public function getLength(string $file): int
68+
{
69+
if (!array_key_exists($file, $this->locations)) {
70+
throw new CachedExportedNodesUnreadableException(sprintf('The exported nodes of %s are not in the cache file.', $file));
71+
}
72+
73+
return $this->locations[$file][1];
74+
}
75+
76+
/**
77+
* @return list<string>
78+
*/
79+
public function getFiles(): array
80+
{
81+
return array_keys($this->locations);
82+
}
83+
84+
/**
85+
* @param array<string, mixed> $files
86+
*/
87+
public function only(array $files): self
88+
{
89+
$locations = [];
90+
foreach ($this->locations as $file => $location) {
91+
if (!array_key_exists($file, $files)) {
92+
continue;
93+
}
94+
95+
$locations[$file] = $location;
96+
}
97+
98+
return new self($this->handle, $locations);
99+
}
100+
101+
/**
102+
* @param array<string, mixed> $files
103+
*/
104+
public function without(array $files): self
105+
{
106+
$locations = $this->locations;
107+
foreach (array_keys($files) as $file) {
108+
unset($locations[$file]);
109+
}
110+
111+
return new self($this->handle, $locations);
112+
}
113+
114+
/**
115+
* @return array<RootExportedNode>
116+
*/
117+
public function decode(string $file): array
118+
{
119+
$nodes = @unserialize($this->read($file));
120+
if (!is_array($nodes)) {
121+
throw new CachedExportedNodesUnreadableException(sprintf('The exported nodes of %s could not be unserialized.', $file));
122+
}
123+
124+
/** @var array<RootExportedNode> $nodes */
125+
return $nodes;
126+
}
127+
128+
/**
129+
* The serialized nodes, as they are in the file.
130+
*/
131+
public function read(string $file): string
132+
{
133+
if (!array_key_exists($file, $this->locations) || !is_resource($this->handle)) {
134+
throw new CachedExportedNodesUnreadableException(sprintf('The exported nodes of %s are not in the cache file.', $file));
135+
}
136+
137+
[$offset, $length] = $this->locations[$file];
138+
if (fseek($this->handle, $offset) !== 0) {
139+
throw new CachedExportedNodesUnreadableException(sprintf('Cannot seek to the exported nodes of %s.', $file));
140+
}
141+
142+
$contents = fread($this->handle, $length);
143+
if ($contents === false || strlen($contents) !== $length) {
144+
throw new CachedExportedNodesUnreadableException(sprintf('Cannot read the exported nodes of %s.', $file));
145+
}
146+
147+
return $contents;
148+
}
149+
150+
public function getOffset(string $file): int
151+
{
152+
if (!array_key_exists($file, $this->locations)) {
153+
throw new CachedExportedNodesUnreadableException(sprintf('The exported nodes of %s are not in the cache file.', $file));
154+
}
155+
156+
return $this->locations[$file][0];
157+
}
158+
159+
/**
160+
* Bytes of the cache file - the serialized nodes of several files stored one after another.
161+
*
162+
* @param positive-int $length
163+
*/
164+
public function readRange(int $offset, int $length): string
165+
{
166+
if (!is_resource($this->handle) || fseek($this->handle, $offset) !== 0) {
167+
throw new CachedExportedNodesUnreadableException(sprintf('Cannot seek to offset %d of the cache file.', $offset));
168+
}
169+
170+
$contents = fread($this->handle, $length);
171+
if ($contents === false || strlen($contents) !== $length) {
172+
throw new CachedExportedNodesUnreadableException(sprintf('Cannot read %d bytes at offset %d of the cache file.', $length, $offset));
173+
}
174+
175+
return $contents;
176+
}
177+
178+
public function close(): void
179+
{
180+
if (is_resource($this->handle)) {
181+
fclose($this->handle);
182+
}
183+
184+
$this->handle = null;
185+
}
186+
187+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\ResultCache;
4+
5+
use RuntimeException;
6+
7+
/**
8+
* The result cache file no longer holds what its index says - restore() discards such a cache.
9+
*/
10+
final class CachedExportedNodesUnreadableException extends RuntimeException
11+
{
12+
13+
}

‎src/Analyser/ResultCache/ResultCache.php‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class ResultCache
2525
* @param array<string, array<string>> $dependencies
2626
* @param array<string, array<string>> $usedTraitDependencies
2727
* @param array<string, array<string>> $packageDependencies
28-
* @param array<string, array<RootExportedNode>> $exportedNodes
28+
* @param array<string, array<RootExportedNode>> $exportedNodes the decoded ones - see $cachedExportedNodes for the rest
2929
* @param array<string, array{string, bool, string}> $projectExtensionFiles
3030
* @param array<string, string> $currentFileHashes
3131
*/
@@ -44,6 +44,7 @@ public function __construct(
4444
private array $usedTraitDependencies,
4545
private array $packageDependencies,
4646
private array $exportedNodes,
47+
private CachedExportedNodes $cachedExportedNodes,
4748
private array $projectExtensionFiles,
4849
private array $currentFileHashes,
4950
)
@@ -157,6 +158,14 @@ public function getExportedNodes(): array
157158
return $this->exportedNodes;
158159
}
159160

161+
/**
162+
* The exported nodes carried over from the cache file undecoded, for the files not in getExportedNodes().
163+
*/
164+
public function getCachedExportedNodes(): CachedExportedNodes
165+
{
166+
return $this->cachedExportedNodes;
167+
}
168+
160169
/**
161170
* @return array<string, array{string, bool, string}>
162171
*/

0 commit comments

Comments
 (0)