[Caching] Use atomic rename() on non-Windows in FileCacheStorage to avoid partially written cache reads on parallel run - #8426
samsonasik wants to merge 4 commits into
Conversation
…void partially written cache reads on parallel run
|
@dragosprotung could you try manually verify if this works ? Thank you. |
|
This seems very costly on every cache call. |
|
@TomasVotruba we originally follow that, and cause windows issue, see original issue and PR that fix rename issue: that was resolved by using as back to |
|
Not sure it was the sole Windows causing issue, that would have been spotted in our CI. |
|
see still open php issue on rename we still support php 7.4 on scoped build. |
|
@samsonasik using rename works |
|
@TomasVotruba I've updated to php-src include issue: in the comment and make less diff so easier to review. Should be ready now 👍 |
|
This still executes a condition on every call. Very costly. Instead, we should trigger fallback only in case of write failure. $copySuccess = @\copy($tmpPath, $filePath);
if ($copySuccess) {
return;
}
// try again here |
|
Copy does not fail, it's just that in parallel run, the cache file is written by different processes at the same time and it gets corrupted. |
|
We'll need a failing reproducer in our CI, so we avoid changing this back and forth. |
|
@TomasVotruba as per @dragosprotung above, this can't one function too early call and fallback later. It not run 2 function, it just verify which OS it run via directory_separator, if it windows, use copy, otherwise, use rename. The |
|
@TomasVotruba It can't be proven here, since the issue is on php < 8.1 on windows. |
|
@samsonasik We can setup any version in CI, even Windows |
|
this repo is on php 8.2, the test require php <8.1, so it needs merge first with e2e on scoped build? |
|
Even on scoped build, I am not sure if CI can setup UAC protected locations windows setup. |
|
@dragosprotung I am thinking if |
|
@TomasVotruba I've updated to |
|
@samsonasik i think this is the best approach, since copy is needed only in a very specific situation |
|
@TomasVotruba this one should be ready now 👍 |
| @@ -70,16 +70,35 @@ public function save(string $key, string $variableKey, mixed $data): void | |||
|
|
|||
| // for performance reasons we don't use SmartFileSystem | |||
| FileSystem::write($tmpPath, \sprintf("<?php declare(strict_types = 1);\n\nreturn %s;", $exported), null); | |||
There was a problem hiding this comment.
you might consider dropping your workarounds and use FileSystem::writeAtomic instead
nette/utils@cc0326d#diff-0e93d4e4e78af9e32c960e4a2b36d8339bb2c76fa8b84ba8fe7df03cd9d542fe
There was a problem hiding this comment.
is that cover windows issue? The windows needs copy instead of rename.
There was a problem hiding this comment.
a automated test should tell
There was a problem hiding this comment.
As I noted in previous comment, that' need php < 8.1, and that's only after scoped build deployed, and even on scoped build, I am not sure if CI can setup UAC protected locations windows setup.
|
@TomasVotruba anything blocking to merge this ? |
|
@dragosprotung Reproducer tests that is failing now, so we avoid blind back and forth commits. |
|
Not sure how this can be added in a test since is a race condition. I run rector on a 32 threads machine and i get it all the time. if i switch to 2 cores, not so often. |
|
@dragosprotung @TomasVotruba I am not sure about reproduce it on CI, as it require setup UAC protected locations windows. The only way is to check manually on that as far as I can see. |
|
We hit this on Linux, which I think reframes the reproducer discussion a bit.
Linux, PHP 8.5,
It's always the This is what PHPStan does
Worth underlining how closely this PR already lands on that. PHPStan 2.2.14 FileWriter::write($tmpPath, "<?php declare(strict_types = 1);\n\n" . '// ' . $key . "\nreturn " . $exported . ';');
$renameSuccess = @rename($tmpPath, $path);
if ($renameSuccess) {
return;
}
@unlink($tmpPath);
if (DIRECTORY_SEPARATOR === '/' || !is_file($path)) {
throw new InvalidArgumentException(sprintf('Could not write data to cache file %s.', $path));
}Same temp file, same That's not a coincidence of style: the class docblock still says "Inspired by https://github.com/phpstan/phpstan-src/.../src/Cache/FileCacheStorage.php", and #498 was literally titled "Sync FileCacheStorage with changes from phpstan-src". On the reproducer
Here's a test that fails on public function testSaveLeavesAConcurrentReaderOnACompleteFile(): void
{
$filePath = __DIR__ . '/Source/0e/76/0e76658526655756207688271159624026011393.php';
$this->fileCacheStorage->save('aaK1STfY', 'TEST', 'first');
$contentsBeforeSave = (string) file_get_contents($filePath);
// every parallel worker require()s this path while booting its container; open it the
// way such a worker would, then have another worker save over it mid-read
$readerHandle = fopen($filePath, 'r');
$this->assertNotFalse($readerHandle);
$this->fileCacheStorage->save('aaK1STfY', 'TEST', 'second');
$contentsSeenByReader = stream_get_contents($readerHandle);
fclose($readerHandle);
// an atomic replace leaves the reader on the whole file it opened; writing through the
// published inode instead exposes the reader to however much of the new file has landed
$this->assertSame($contentsBeforeSave, $contentsSeenByReader);
$this->assertSame('second', $this->fileCacheStorage->load('aaK1STfY', 'TEST'));
$this->fileCacheStorage->clean('aaK1STfY');
}On current The handle was opened before the save and never re-opened, yet it reads To be straight about what this does and doesn't prove: it is not a reproduction of the crash. It pins the precondition — a writer mutating a file under an open reader. It doesn't catch a torn file, because with a small payload Happy to send it as a PR against @dragosprotung — this matches your read exactly, FWIW, including the 32-threads-vs-2-cores frequency. |
|
Resolved in #8520 |
Fixes rectorphp/rector#9876