From 1b55e650ca7da0e366c444325ec55720ef97cfe8 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Tue, 10 Nov 2026 14:28:14 +0000 Subject: [PATCH] Speed up reads from Statamic's file cache store Laravel's FileStore::get() checks the file exists before reading it and reads the clock twice, even for items stored forever. The Stache reads far more often than it writes and almost never stores items that expire, so open the file directly and only check the clock for items that can expire. Keep the shared lock, and forget expired or corrupt files as Laravel does. Co-Authored-By: Claude Opus 5.5 --- src/Extensions/FileStore.php | 59 +++++++++++++++++ tests/Extensions/FileStoreTest.php | 103 +++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/src/Extensions/FileStore.php b/src/Extensions/FileStore.php index 04bfe853edb..ce1d5836343 100644 --- a/src/Extensions/FileStore.php +++ b/src/Extensions/FileStore.php @@ -5,9 +5,15 @@ use Illuminate\Cache\FileStore as LaravelFileStore; use Illuminate\Contracts\Cache\Store; use Statamic\Support\Str; +use Throwable; class FileStore extends LaravelFileStore implements Store { + /** + * The expiration Laravel writes for items stored forever. + */ + private const FOREVER = '9999999999'; + private ?string $dir = null; public function path($key) @@ -31,4 +37,57 @@ private function dir() ? $this->directory.'/' : $this->directory.'/stache/'; } + + /** + * Retrieve an item from the cache by key. + * + * Behaves like the parent, but the Stache reads far more often than it writes and + * almost everything it stores never expires. So this opens the file directly rather + * than checking it exists first, and only checks the clock for items that can expire. + */ + public function get($key) + { + if (! $handle = @fopen($this->path($key), 'rb')) { + return null; + } + + try { + // Like the parent, take a shared lock so we never read a file mid-write. + if (! flock($handle, LOCK_SH)) { + return null; + } + + $contents = stream_get_contents($handle); + + flock($handle, LOCK_UN); + } finally { + fclose($handle); + } + + $expire = substr($contents, 0, 10); + + if ($expire !== self::FOREVER && $this->currentTime() >= (int) $expire) { + $this->forget($key); + + return null; + } + + try { + return $this->unserializeValue(substr($contents, 10)); + } catch (Throwable) { + $this->forget($key); + + return null; + } + } + + private function unserializeValue(string $value) + { + // Laravel only supports restricting unserializable classes in newer versions. + if (method_exists(LaravelFileStore::class, 'unserialize')) { + return $this->unserialize($value); + } + + return unserialize($value); + } } diff --git a/tests/Extensions/FileStoreTest.php b/tests/Extensions/FileStoreTest.php index cfaa71d396a..79adce0f390 100644 --- a/tests/Extensions/FileStoreTest.php +++ b/tests/Extensions/FileStoreTest.php @@ -3,14 +3,35 @@ namespace Tests\Extensions; use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\FileStore as LaravelFileStore; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Orchestra\Testbench\Attributes\DefineEnvironment; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Extensions\FileStore; use Tests\TestCase; class FileStoreTest extends TestCase { + private $dir; + + public function setUp(): void + { + parent::setUp(); + + $this->dir = __DIR__.'/tmp-file-store'; + } + + public function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->dir); + Carbon::setTestNow(); + + parent::tearDown(); + } + #[Test] #[DefineEnvironment('cache')] public function it_overrides_file_driven_stores() @@ -44,4 +65,86 @@ public function cache($app) 'driver' => 'array', ]); } + + public static function keyProvider() + { + return [ + 'stache key' => ['stache::items::entries::articles::'], + 'other key' => ['other-'], + ]; + } + + #[Test] + #[DataProvider('keyProvider')] + public function it_reads_values($prefix) + { + $store = new FileStore(new Filesystem, $this->dir); + + $store->forever($prefix.'array', ['foo' => 'bar']); + $store->forever($prefix.'false', false); + $store->forever($prefix.'null', null); + $store->put($prefix.'expiring', 'baz', 60); + + $this->assertEquals(['foo' => 'bar'], $store->get($prefix.'array')); + $this->assertFalse($store->get($prefix.'false')); + $this->assertNull($store->get($prefix.'null')); + $this->assertEquals('baz', $store->get($prefix.'expiring')); + $this->assertNull($store->get($prefix.'missing')); + $this->assertEquals( + [$prefix.'array' => ['foo' => 'bar'], $prefix.'missing' => null], + $store->many([$prefix.'array', $prefix.'missing']) + ); + } + + #[Test] + #[DataProvider('keyProvider')] + public function expired_values_are_forgotten($prefix) + { + $store = new FileStore(new Filesystem, $this->dir); + + Carbon::setTestNow(Carbon::parse('2025-01-01 00:00:00')); + $store->put($prefix.'expiring', 'baz', 60); + $store->forever($prefix.'forever', 'qux'); + + Carbon::setTestNow(Carbon::parse('2025-01-01 00:01:00')); + + $this->assertNull($store->get($prefix.'expiring')); + $this->assertFileDoesNotExist($store->path($prefix.'expiring')); + $this->assertEquals('qux', $store->get($prefix.'forever')); + } + + #[Test] + #[DataProvider('corruptFileProvider')] + public function corrupt_files_are_forgotten($contents) + { + $store = new FileStore(new Filesystem, $this->dir); + $path = $store->path('stache::items::entries::articles::corrupt'); + @mkdir(dirname($path), 0777, true); + file_put_contents($path, $contents); + + $this->assertNull($store->get('stache::items::entries::articles::corrupt')); + $this->assertFileDoesNotExist($path); + } + + public static function corruptFileProvider() + { + return [ + 'empty' => [''], + 'only part of the expiration' => ['99999'], + 'truncated value' => ['9999999999'.substr(serialize(['foo' => str_repeat('bar', 100)]), 0, 50)], + ]; + } + + #[Test] + public function it_only_unserializes_allowed_classes_when_configured() + { + if (! method_exists(LaravelFileStore::class, 'unserialize')) { + $this->markTestSkipped('This version of Laravel does not support restricting unserializable classes.'); + } + + $store = new FileStore(new Filesystem, $this->dir, null, []); + $store->forever('stache::items::entries::articles::object', new \ArrayObject(['foo' => 'bar'])); + + $this->assertInstanceOf(\__PHP_Incomplete_Class::class, $store->get('stache::items::entries::articles::object')); + } }