refactor(build): centralize atomic cache writes

master
韩天峰 3 weeks ago
parent 687b7ac6cb
commit 5c3da4e406
  1. 67
      phpunit/src/Build/AtomicFileTest.php
  2. 28
      src/Build/AstCache.php
  3. 64
      src/Build/AtomicFile.php
  4. 9
      src/Build/EmbeddedArchiveBuilder.php
  5. 24
      src/Build/IncrementalBuildState.php
  6. 15
      src/Build/PrecompiledHeaderManager.php
  7. 14
      src/Build/PreparedProjectCacheTrait.php
  8. 24
      src/Build/StableIdRegistry.php

@ -0,0 +1,67 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Tests\Build;
use PHPUnit\Framework\TestCase;
use TypePhp\Build\AtomicFile;
/**
* @internal
* @coversNothing
*/
final class AtomicFileTest extends TestCase
{
private string $directory;
protected function setUp(): void
{
$this->directory = sys_get_temp_dir() . '/typephp-atomic-file-' . bin2hex(random_bytes(6));
}
protected function tearDown(): void
{
if (!is_dir($this->directory)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $entry) {
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname());
}
rmdir($this->directory);
}
public function testWriteCreatesDirectoriesAndReplacesExistingContents(): void
{
$path = $this->directory . '/nested/state.json';
AtomicFile::write($path, 'first', '.state-');
AtomicFile::write($path, 'second', '.state-');
self::assertSame('second', file_get_contents($path));
self::assertSame([$path], glob($this->directory . '/nested/*'));
}
public function testMissingReplacementDoesNotRemoveExistingTarget(): void
{
$target = $this->directory . '/state.json';
AtomicFile::write($target, 'stable');
try {
AtomicFile::replace($this->directory . '/missing.tmp', $target);
self::fail('Missing replacement must be rejected');
} catch (\RuntimeException $error) {
self::assertStringContainsString('does not exist', $error->getMessage());
}
self::assertSame('stable', file_get_contents($target));
}
}

@ -100,13 +100,6 @@ final class AstCache
private function store(string $cacheFile, string $source, string $sourceHash, array $ast): void
{
try {
if (!is_dir($this->cacheDirectory)
&& !mkdir($this->cacheDirectory, 0777, true)
&& !is_dir($this->cacheDirectory)
) {
return;
}
$contents = serialize([
'schema' => self::SCHEMA_VERSION,
'phpVersion' => $this->phpVersion,
@ -115,26 +108,7 @@ final class AstCache
'sourceHash' => $sourceHash,
'ast' => $ast,
]);
$temporary = tempnam($this->cacheDirectory, '.ast-');
if ($temporary === false) {
return;
}
try {
if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
return;
}
if (!@rename($temporary, $cacheFile)) {
// Windows cannot atomically replace an existing file. A
// concurrent reader may safely treat the short gap as a
// cache miss and rebuild the disposable snapshot.
@unlink($cacheFile);
@rename($temporary, $cacheFile);
}
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
AtomicFile::write($cacheFile, $contents, '.ast-');
} catch (\Throwable) {
// AST caching is an optimization. Serialization or filesystem
// failures must never prevent an otherwise valid compilation.

@ -0,0 +1,64 @@
<?php
/**
* This file is part of TypePHP(AOT).
*
* @link https://www.swoole.com/aot/
* @contact service@swoole.com
*/
namespace TypePhp\Build;
final class AtomicFile
{
public static function write(string $path, string $contents, string $temporaryPrefix = '.tmp-'): void
{
$directory = dirname($path);
self::ensureDirectory($directory);
$temporary = tempnam($directory, $temporaryPrefix);
if ($temporary === false) {
throw new \RuntimeException("Cannot create temporary file for: {$path}");
}
try {
if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
throw new \RuntimeException("Cannot write temporary file for: {$path}");
}
self::replace($temporary, $path);
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
}
/** Replace a target with a temporary file created in the same directory. */
public static function replace(string $temporary, string $target): void
{
if (!is_file($temporary)) {
throw new \RuntimeException("Replacement file does not exist: {$temporary}");
}
if (@rename($temporary, $target)) {
return;
}
// Windows cannot rename over an existing file. A concurrent reader may
// safely treat this short gap as a cache miss and rebuild the snapshot.
if (PHP_OS_FAMILY === 'Windows' && is_file($target)) {
if (!@unlink($target)) {
throw new \RuntimeException("Cannot remove file before replacement: {$target}");
}
if (@rename($temporary, $target)) {
return;
}
}
throw new \RuntimeException("Cannot replace file: {$target}");
}
private static function ensureDirectory(string $directory): void
{
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create directory: {$directory}");
}
}
}

@ -60,13 +60,12 @@ final class EmbeddedArchiveBuilder
throw new \RuntimeException("Cannot remove unchanged embedded archive: {$temporaryPath}");
}
} else {
if (is_file($archivePath) && !unlink($archivePath)) {
try {
AtomicFile::replace($temporaryPath, $archivePath);
} finally {
if (is_file($temporaryPath)) {
@unlink($temporaryPath);
throw new \RuntimeException("Cannot replace embedded file archive: {$archivePath}");
}
if (!rename($temporaryPath, $archivePath)) {
@unlink($temporaryPath);
throw new \RuntimeException("Cannot install embedded file archive: {$archivePath}");
}
}

@ -115,29 +115,7 @@ final class IncrementalBuildState
'generatorFingerprint' => $generatorFingerprint,
'files' => $files,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
$directory = dirname($this->file);
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create incremental build state directory: ' . $directory);
}
$temporary = tempnam($directory, '.graph-');
if ($temporary === false) {
throw new \RuntimeException('Cannot create incremental build state temporary file');
}
try {
if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write incremental build state: ' . $this->file);
}
if (!@rename($temporary, $this->file)) {
@unlink($this->file);
if (!@rename($temporary, $this->file)) {
throw new \RuntimeException('Cannot write incremental build state: ' . $this->file);
}
}
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
AtomicFile::write($this->file, $contents, '.graph-');
$this->generatorFingerprint = $generatorFingerprint;
$this->files = $files;
}

@ -183,15 +183,14 @@ final readonly class PrecompiledHeaderManager
}
if ($digests !== $saved && is_dir($cacheDirectory)) {
$temporary = tempnam($cacheDirectory, '.pch-digests-');
if ($temporary !== false) {
try {
if (file_put_contents($temporary, json_encode($digests, JSON_THROW_ON_ERROR)) !== false) {
@rename($temporary, $digestFile);
}
} finally {
@unlink($temporary);
}
AtomicFile::write(
$digestFile,
json_encode($digests, JSON_THROW_ON_ERROR),
'.pch-digests-',
);
} catch (\Throwable) {
// Digest reuse is optional; hashing every dependency remains correct.
}
}

@ -98,7 +98,6 @@ trait PreparedProjectCacheTrait
if ($this->externalImportStubFiles !== []) {
return;
}
$temporary = '';
$bodies = [];
try {
// Conversion always loads a fresh pristine AST. The prepared tree
@ -133,24 +132,13 @@ trait PreparedProjectCacheTrait
$state[$field] = $this->{$field};
}
$file = $this->preparedProjectCacheFile();
if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0777, true) && !is_dir(dirname($file))) {
return;
}
$candidate = tempnam(dirname($file), '.prepared-');
if (is_string($candidate)) {
$temporary = $candidate;
$this->writeFile($temporary, $key . "\n" . serialize($state));
@rename($temporary, $file);
}
AtomicFile::write($file, $key . "\n" . serialize($state), '.prepared-');
} catch (\Throwable) {
// Optional cache failures must not fail a valid compilation.
} finally {
foreach ($bodies as [$node, $statements]) {
$node->stmts = $statements;
}
if (is_string($temporary) && is_file($temporary)) {
@unlink($temporary);
}
}
}
}

@ -65,34 +65,12 @@ final class StableIdRegistry
if (!$this->dirty || $this->file === '') {
return;
}
$directory = dirname($this->file);
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) {
throw new \RuntimeException('Cannot create stable ID cache directory: ' . $directory);
}
$contents = json_encode([
'schema' => self::SCHEMA_VERSION,
'domains' => $this->domains,
'nextIds' => $this->nextIds,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
$temporary = tempnam($directory, '.ids-');
if ($temporary === false) {
throw new \RuntimeException('Cannot create stable ID cache temporary file');
}
try {
if (file_put_contents($temporary, $contents, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write stable ID cache: ' . $this->file);
}
if (!@rename($temporary, $this->file)) {
@unlink($this->file);
if (!@rename($temporary, $this->file)) {
throw new \RuntimeException('Cannot replace stable ID cache: ' . $this->file);
}
}
} finally {
if (is_file($temporary)) {
@unlink($temporary);
}
}
AtomicFile::write($this->file, $contents, '.ids-');
$this->dirty = false;
}

Loading…
Cancel
Save