Skip to content

Commit d6b205a

Browse files
authored
[Caching] Use FileSystem::writeAtomic() in FileCacheStorage to avoid partial cache reads on parallel run (#8520)
1 parent ee8e978 commit d6b205a

4 files changed

Lines changed: 72 additions & 15 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: File Cache Storage Test
2+
3+
on:
4+
pull_request: null
5+
push:
6+
branches:
7+
- main
8+
9+
10+
11+
env:
12+
# see https://github.com/composer/composer/issues/9368#issuecomment-718112361
13+
COMPOSER_ROOT_VERSION: "dev-main"
14+
15+
jobs:
16+
tests:
17+
strategy:
18+
fail-fast: false
19+
matrix:
20+
os: [ubuntu-latest, windows-latest]
21+
php-versions: ['8.4']
22+
23+
runs-on: ${{ matrix.os }}
24+
timeout-minutes: 3
25+
26+
name: File Cache Storage ${{ matrix.php-versions }} (${{ matrix.os }})
27+
steps:
28+
- uses: actions/checkout@v5
29+
30+
-
31+
uses: shivammathur/setup-php@v2
32+
with:
33+
php-version: ${{ matrix.php-versions }}
34+
coverage: none
35+
ini-values: zend.assertions=1
36+
37+
- uses: "ramsey/composer-install@v4"
38+
39+
- run: vendor/bin/phpunit tests/Caching/ValueObject/Storage/FileCacheStorageTest.php --colors

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"doctrine/inflector": "^2.1",
2121
"entropy/entropy": "^0.4.12",
2222
"fidry/cpu-core-counter": "^1.1",
23-
"nette/utils": "^4.1.4",
23+
"nette/utils": "^4.1.5",
2424
"nikic/php-parser": "^5.9",
2525
"ondram/ci-detector": "^4.2",
2626
"phpstan/phpdoc-parser": "^2.3.3",

‎src/Caching/ValueObject/Storage/FileCacheStorage.php‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use FilesystemIterator;
88
use Nette\Utils\FileSystem;
9-
use Nette\Utils\Random;
109
use Rector\Caching\Contract\ValueObject\Storage\CacheStorageInterface;
1110
use Rector\Caching\ValueObject\CacheFilePaths;
1211
use Rector\Caching\ValueObject\CacheItem;
@@ -55,7 +54,6 @@ public function save(string $key, string $variableKey, mixed $data): void
5554

5655
$filePath = $cacheFilePaths->getFilePath();
5756

58-
$tmpPath = \sprintf('%s/%s.tmp', $this->directory, Random::generate());
5957
$errorBefore = \error_get_last();
6058
$exported = @\var_export(new CacheItem($variableKey, $data), true);
6159
$errorAfter = \error_get_last();
@@ -68,18 +66,9 @@ public function save(string $key, string $variableKey, mixed $data): void
6866
));
6967
}
7068

71-
// for performance reasons we don't use SmartFileSystem
72-
FileSystem::write($tmpPath, \sprintf("<?php declare(strict_types = 1);\n\nreturn %s;", $exported), null);
73-
$copySuccess = @\copy($tmpPath, $filePath);
74-
@\unlink($tmpPath);
75-
76-
if ($copySuccess) {
77-
return;
78-
}
79-
80-
if (\DIRECTORY_SEPARATOR === '/' || ! \file_exists($filePath)) {
81-
throw new CachingException(\sprintf('Could not write data to cache file %s.', $filePath));
82-
}
69+
// atomic write via temp file + rename(), so a parallel reader never sees a partially written cache file
70+
// handles the Windows rename() edge case too, see https://github.com/rectorphp/rector/issues/9876
71+
FileSystem::writeAtomic($filePath, \sprintf("<?php declare(strict_types = 1);\n\nreturn %s;", $exported), null);
8372
}
8473

8574
public function clean(string $key): void

‎tests/Caching/ValueObject/Storage/FileCacheStorageTest.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,35 @@ public function testClean(): void
4949
$this->assertDirectoryDoesNotExist(__DIR__ . '/Source/0e');
5050
}
5151

52+
public function testSaveLeavesConcurrentReaderOnCompleteFile(): void
53+
{
54+
if (\DIRECTORY_SEPARATOR === '\\') {
55+
// Windows blocks rename() over a file open in another handle, so the atomic-replace-under-open-reader
56+
// scenario this asserts is POSIX-only; on Windows writeAtomic() retries the transient lock instead
57+
$this->markTestSkipped('Atomic replace under an open reader is POSIX-only');
58+
}
59+
60+
$filePath = __DIR__ . '/Source/0e/76/0e76658526655756207688271159624026011393.php';
61+
62+
$this->fileCacheStorage->save('aaK1STfY', 'TEST', 'first');
63+
$contentsBeforeSave = (string) file_get_contents($filePath);
64+
65+
// every parallel worker require()s this path while booting; open it as such a worker would,
66+
// then save over it mid-read - an atomic write must leave the reader on the file it opened
67+
$readerHandle = fopen($filePath, 'r');
68+
$this->assertNotFalse($readerHandle);
69+
70+
$this->fileCacheStorage->save('aaK1STfY', 'TEST', 'second');
71+
72+
$contentsSeenByReader = stream_get_contents($readerHandle);
73+
fclose($readerHandle);
74+
75+
$this->assertSame($contentsBeforeSave, $contentsSeenByReader);
76+
$this->assertSame('second', $this->fileCacheStorage->load('aaK1STfY', 'TEST'));
77+
78+
$this->fileCacheStorage->clean('aaK1STfY');
79+
}
80+
5281
public function provideConfigFilePath(): string
5382
{
5483
return __DIR__ . '/config.php';

0 commit comments

Comments
 (0)