diff --git a/src/Storage/FileStorage.php b/src/Storage/FileStorage.php index 113850d07..83ea0c519 100644 --- a/src/Storage/FileStorage.php +++ b/src/Storage/FileStorage.php @@ -6,9 +6,11 @@ use Yiisoft\Files\FileHelper; +use function array_filter; use function array_slice; use function count; use function dirname; +use function file_exists; use function filemtime; use function glob; use function json_decode; @@ -113,9 +115,17 @@ private function findFilesOrderedByModifiedTime(string $pattern): array usort( $files, - static fn (string $a, string $b) => filemtime($b) <=> filemtime($a) + static function (string $a, string $b): int { + // Use @ to suppress warnings when files are deleted concurrently by another process + $mtimeA = @filemtime($a); + $mtimeB = @filemtime($b); + // filemtime() returns false if file doesn't exist, treat as 0 for sorting + return ($mtimeB ?: 0) <=> ($mtimeA ?: 0); + } ); - return $files; + + // Filter out files that no longer exist (due to concurrent deletion) + return array_filter($files, static fn(string $file): bool => @file_exists($file)); } private function encode(mixed $value): string diff --git a/tests/Unit/Storage/FileStorageTest.php b/tests/Unit/Storage/FileStorageTest.php index a139667fd..38034712c 100644 --- a/tests/Unit/Storage/FileStorageTest.php +++ b/tests/Unit/Storage/FileStorageTest.php @@ -51,6 +51,34 @@ public function testClear(): void $this->assertDirectoryDoesNotExist($this->path); } + public function testConcurrentDeletion(): void + { + $storage = $this->getStorage(); + $storage->setHistorySize(10); + + // Write some data + for ($i = 1; $i <= 5; $i++) { + $storage->write('test' . $i, [['data' . $i]], [], ['id' => 'test' . $i]); + usleep(1000); // 1ms delay to ensure different modification times + } + + // Find all summary files + $pattern = $this->path . '/**/**/summary.json'; + $summaryFiles = glob($pattern, GLOB_NOSORT); + $this->assertNotEmpty($summaryFiles, 'Should have summary files'); + + // Delete one summary file to simulate concurrent deletion during gc + if (!empty($summaryFiles)) { + unlink($summaryFiles[0]); + } + + // This should not produce any warnings even though a file was deleted + $summary = $storage->read(StorageInterface::TYPE_SUMMARY); + + // We should get 4 results (5 written - 1 deleted) + $this->assertCount(4, $summary); + } + public function getStorage(): FileStorage { return new FileStorage($this->path);