From 9cb98a14e34162284e318073d3c13cac3e9587f7 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 15:00:27 -0700 Subject: [PATCH 1/8] Add tests for keeping meta files out of the container listing cache --- tests/Assets/AssetContainerTest.php | 59 +++++++++++++++++++++++------ tests/Assets/AssetTest.php | 16 ++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 21256960450..8d1ccefa7b8 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -757,12 +757,8 @@ public function it_still_only_gets_the_folders_from_the_cache_and_blink_once_per #[Test] public function it_does_not_leak_stale_contents_state_across_calls_when_running_in_a_queue_worker() { - $cacheKey = 'asset-list-contents-test'; - - Cache::put($cacheKey, collect([ - 'a.txt' => ['type' => 'file', 'path' => 'a.txt', 'dirname' => ''], - '.meta/a.txt.yaml' => ['type' => 'file', 'path' => '.meta/a.txt.yaml', 'dirname' => '.meta'], - ])); + Storage::fake('test'); + Storage::disk('test')->put('.meta/a.txt.yaml', ''); $container = (new AssetContainer)->handle('test')->disk('test'); @@ -778,12 +774,7 @@ public function it_does_not_leak_stale_contents_state_across_calls_when_running_ ); // Simulate the next job seeing a different state on disk. - Cache::put($cacheKey, collect([ - 'a.txt' => ['type' => 'file', 'path' => 'a.txt', 'dirname' => ''], - '.meta/a.txt.yaml' => ['type' => 'file', 'path' => '.meta/a.txt.yaml', 'dirname' => '.meta'], - 'b.txt' => ['type' => 'file', 'path' => 'b.txt', 'dirname' => ''], - '.meta/b.txt.yaml' => ['type' => 'file', 'path' => '.meta/b.txt.yaml', 'dirname' => '.meta'], - ])); + Storage::disk('test')->put('.meta/b.txt.yaml', ''); // Simulate the job boundary: Laravel clears resolved facade instances // before every job. @@ -795,6 +786,50 @@ public function it_does_not_leak_stale_contents_state_across_calls_when_running_ ); } + #[Test] + public function it_does_not_cache_meta_files_or_directories_in_the_listing() + { + $disk = $this->mock(Filesystem::class); + $disk->shouldReceive('filesystem->getDriver->listContents') + ->with('/', true) + ->once() + ->andReturn(new DirectoryListing([ + new DirectoryAttributes('.meta'), + new FileAttributes('.meta/one.jpg.yaml'), + new FileAttributes('one.jpg'), + new DirectoryAttributes('one/.meta'), + new FileAttributes('one/.meta/two.jpg.yaml'), + new FileAttributes('one/two.jpg'), + ])); + + File::shouldReceive('disk')->with('test')->andReturn($disk); + + $container = (new AssetContainer)->handle('test')->disk('test'); + + $this->assertEquals(['one.jpg', 'one/two.jpg'], $container->files()->all()); + $this->assertEquals( + ['one', 'one.jpg', 'one/two.jpg'], + Cache::get('asset-list-contents-test')->keys()->all() + ); + } + + #[Test] + public function it_gets_meta_files_from_the_filesystem_when_the_listing_is_cached_without_them() + { + Cache::put('asset-list-contents-test', collect([ + 'a.txt' => ['type' => 'file', 'path' => 'a.txt', 'dirname' => ''], + ])); + + $this->assertEquals([ + '.meta/a.txt.yaml', + '.meta/b.txt.yaml', + 'nested/.meta/nested-a.txt.yaml', + 'nested/.meta/nested-b.txt.yaml', + 'nested/double-nested/.meta/double-nested-a.txt.yaml', + 'nested/double-nested/.meta/double-nested-b.txt.yaml', + ], $this->containerWithDisk()->metaFiles()->all()); + } + #[Test] public function it_gets_an_asset() { diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index c5fde7bc263..8c9219f4ebb 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -2904,6 +2904,22 @@ public function it_resolves_pending_original_meta_values_when_hydrating() ], $asset->getRawOriginal()); } + #[Test] + public function it_resolves_pending_original_meta_values_from_disk_when_the_listing_is_loaded() + { + Storage::disk('test')->put('path/to/test.txt', ''); + + // Warm the container listing before the meta file exists. + $this->container->files(); + + Storage::disk('test')->put('path/to/.meta/test.txt.yaml', "data:\n alfa: bravo"); + + $asset = (new Asset)->container($this->container)->path('path/to/test.txt'); + $asset->syncOriginal(); + + $this->assertEquals(['alfa' => 'bravo'], $asset->getOriginal('data')); + } + #[Test] #[DataProvider('warmPresetProvider')] public function it_gets_which_presets_to_warm($extension, $orientation, $cpEnabled, $expectedWarm) From d788c512ceda67fcad0cd37ed98389b7a9aff4f2 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 15:01:35 -0700 Subject: [PATCH 2/8] Keep meta files out of the container listing cache --- src/Assets/Asset.php | 2 +- src/Assets/AssetContainerContents.php | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index a432150acbb..295b296dc27 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -315,7 +315,7 @@ public function metaPath() protected function metaExists() { - return $this->container()->metaFiles()->contains($this->metaPath()); + return $this->disk()->exists($this->metaPath()); } public function writeMeta($meta) diff --git a/src/Assets/AssetContainerContents.php b/src/Assets/AssetContainerContents.php index 9c32f2defba..e5a2e0aea92 100644 --- a/src/Assets/AssetContainerContents.php +++ b/src/Assets/AssetContainerContents.php @@ -37,6 +37,7 @@ public function all() return $this->files = $this->cacheStore()->remember($this->key(), $this->ttl(), function () { return collect($this->getRawFlysystemDirectoryListing()) ->keyBy('path') + ->reject(fn ($file, $path) => $this->isMetaPath($path)) ->map(fn ($file) => $this->normalizeFlysystemAttributes($file)) ->pipe(fn ($files) => $this->ensureMissingDirectoriesExist($files)) ->sortKeys(); @@ -182,12 +183,12 @@ public function metaFilesIn($folder, $recursive) return $this->metaFiles[$key]; } - $files = $this->files(); - - $files = $files->filter(function ($file, $path) { - return Str::startsWith($path, '.meta/') - || Str::contains($path, '/.meta/'); - }); + $files = collect($this->filesystem()->listContents($recursive ? $folder : $folder.'/.meta', $recursive)) + ->filter(fn ($attributes) => $attributes->isFile()) + ->keyBy('path') + ->map(fn ($file) => $this->normalizeFlysystemAttributes($file)) + ->filter(fn ($file, $path) => $this->isMetaPath($path)) + ->sortKeys(); // Filter by folder and recursiveness. But don't bother if we're // requesting the root recursively as it's already that way. @@ -268,6 +269,11 @@ private function filesystem() return $this->container->disk()->filesystem()->getDriver(); } + private function isMetaPath($path): bool + { + return (bool) preg_match('#(^|/)\.meta(/|$)#', $path); + } + public function save() { $this->cacheStore()->put($this->key(), $this->all(), $this->ttl()); From f0dfa1b745b527854b2cb118c0fe6195142b5557 Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 15 Oct 2026 19:39:57 -0700 Subject: [PATCH 3/8] Drop redundant folder filter from metaFilesIn --- src/Assets/AssetContainerContents.php | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/Assets/AssetContainerContents.php b/src/Assets/AssetContainerContents.php index e5a2e0aea92..9cea8fb0224 100644 --- a/src/Assets/AssetContainerContents.php +++ b/src/Assets/AssetContainerContents.php @@ -183,27 +183,15 @@ public function metaFilesIn($folder, $recursive) return $this->metaFiles[$key]; } - $files = collect($this->filesystem()->listContents($recursive ? $folder : $folder.'/.meta', $recursive)) + $path = $recursive ? $folder : trim($folder, '/').'/.meta'; + + $files = collect($this->filesystem()->listContents($path, $recursive)) ->filter(fn ($attributes) => $attributes->isFile()) ->keyBy('path') ->map(fn ($file) => $this->normalizeFlysystemAttributes($file)) ->filter(fn ($file, $path) => $this->isMetaPath($path)) ->sortKeys(); - // Filter by folder and recursiveness. But don't bother if we're - // requesting the root recursively as it's already that way. - if ($folder === '/' && $recursive) { - // - } else { - $files = $files->filter(function ($file) use ($folder, $recursive) { - $dir = $file['dirname']; - $dir = substr($dir, 0, -6); // remove .meta/ from the end - $dir = $dir ?: '/'; - - return $recursive ? Str::startsWith($dir, $folder) : $dir == $folder; - }); - } - return $this->metaFiles[$key] = $files; } From 278b2eb784875a6f0c9b176ecca87437dfaf9b78 Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 15 Oct 2026 19:40:01 -0700 Subject: [PATCH 4/8] Fold duplicate meta files test into the default one --- tests/Assets/AssetContainerTest.php | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/tests/Assets/AssetContainerTest.php b/tests/Assets/AssetContainerTest.php index 8d1ccefa7b8..de56d0d4ac6 100644 --- a/tests/Assets/AssetContainerTest.php +++ b/tests/Assets/AssetContainerTest.php @@ -380,6 +380,11 @@ public function it_gets_all_files_by_default() #[Test] public function it_gets_all_meta_files_by_default() { + // The cached listing doesn't include meta files, so they must come from the filesystem. + Cache::put('asset-list-contents-test', collect([ + 'a.txt' => ['type' => 'file', 'path' => 'a.txt', 'dirname' => ''], + ])); + $this->assertEquals([ '.meta/a.txt.yaml', '.meta/b.txt.yaml', @@ -813,23 +818,6 @@ public function it_does_not_cache_meta_files_or_directories_in_the_listing() ); } - #[Test] - public function it_gets_meta_files_from_the_filesystem_when_the_listing_is_cached_without_them() - { - Cache::put('asset-list-contents-test', collect([ - 'a.txt' => ['type' => 'file', 'path' => 'a.txt', 'dirname' => ''], - ])); - - $this->assertEquals([ - '.meta/a.txt.yaml', - '.meta/b.txt.yaml', - 'nested/.meta/nested-a.txt.yaml', - 'nested/.meta/nested-b.txt.yaml', - 'nested/double-nested/.meta/double-nested-a.txt.yaml', - 'nested/double-nested/.meta/double-nested-b.txt.yaml', - ], $this->containerWithDisk()->metaFiles()->all()); - } - #[Test] public function it_gets_an_asset() { From 3b76a49597355854808a025178b4a617b130677b Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 14:31:31 -0700 Subject: [PATCH 5/8] Add tests for asset existence checks skipping the container listing --- tests/Assets/AssetTest.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index 8c9219f4ebb..f069691ce74 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -540,6 +540,38 @@ public function it_checks_if_it_exists() $this->assertFalse($noPath->exists()); } + #[Test] + public function exists_checks_the_disk_without_loading_the_container_listing() + { + Storage::disk('test')->put('yes.txt', ''); + $asset = (new Asset)->container($this->container)->path('yes.txt'); + + $this->assertTrue($asset->exists()); + $this->assertFalse(Cache::has('asset-list-contents-test_container')); + } + + #[Test] + public function exists_uses_the_container_listing_when_it_is_already_loaded() + { + Storage::disk('test')->put('yes.txt', ''); + $this->container->files(); + Storage::disk('test')->put('later.txt', ''); + + $this->assertTrue((new Asset)->container($this->container)->path('yes.txt')->exists()); + $this->assertFalse((new Asset)->container($this->container)->path('later.txt')->exists()); + } + + #[Test] + public function it_resolves_original_data_from_meta_without_loading_the_container_listing() + { + Storage::disk('test')->put('path/to/test.txt', ''); + Storage::disk('test')->put('path/to/.meta/test.txt.yaml', "data:\n foo: bar"); + $asset = (new Asset)->container($this->container)->path('path/to/test.txt'); + + $this->assertEquals(['foo' => 'bar'], $asset->getOriginal()['data']); + $this->assertFalse(Cache::has('asset-list-contents-test_container')); + } + #[Test] public function it_gets_the_filename() { From b397f3c3b9ab36c84fa8be8f0a34bee6153a7089 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 7 Oct 2026 14:39:52 -0700 Subject: [PATCH 6/8] Check asset existence on disk when the container listing isn't loaded --- src/Assets/Asset.php | 4 +++- src/Assets/AssetContainerContents.php | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 295b296dc27..360648d9b39 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -227,7 +227,9 @@ public function exists() return false; } - return $this->container()->files()->contains($path); + return $this->container()->contents()->isLoaded() + ? $this->container()->files()->contains($path) + : $this->disk()->exists($path); } public function getRawMeta() diff --git a/src/Assets/AssetContainerContents.php b/src/Assets/AssetContainerContents.php index 9cea8fb0224..5c8163e4aa7 100644 --- a/src/Assets/AssetContainerContents.php +++ b/src/Assets/AssetContainerContents.php @@ -167,6 +167,11 @@ public function cached() return $this->cacheStore()->get($this->key()); } + public function isLoaded(): bool + { + return $this->files !== null; + } + public function files() { return $this->all()->where('type', 'file'); From 8d85b8730e4df38ec91cd32095121c80da6b4b75 Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 15 Oct 2026 19:42:08 -0700 Subject: [PATCH 7/8] Add test for memoizing the asset disk existence check --- tests/Assets/AssetTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Assets/AssetTest.php b/tests/Assets/AssetTest.php index f069691ce74..66dd5b2f991 100644 --- a/tests/Assets/AssetTest.php +++ b/tests/Assets/AssetTest.php @@ -7,6 +7,7 @@ use Facades\Statamic\Fields\BlueprintRepository; use Facades\Statamic\Imaging\ImageValidator; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -550,6 +551,26 @@ public function exists_checks_the_disk_without_loading_the_container_listing() $this->assertFalse(Cache::has('asset-list-contents-test_container')); } + #[Test] + public function exists_checks_the_disk_once_per_path() + { + Storage::disk('test')->put('yes.txt', ''); + Storage::disk('test')->put('.meta/yes.txt.yaml', "width: 10\nsize: 20\n"); + + $fake = Storage::disk('test'); + $disk = Mockery::mock(FilesystemAdapter::class, [$fake->getDriver(), $fake->getAdapter(), $fake->getConfig()])->makePartial(); + $disk->shouldReceive('exists')->with('yes.txt')->once()->andReturn(true); + $disk->shouldReceive('exists')->with('other.txt')->once()->andReturn(false); + Storage::set('test', $disk); + + $asset = (new Asset)->container($this->container)->path('yes.txt'); + + $this->assertTrue($asset->exists()); + $this->assertEquals(10, $asset->meta('width')); + $this->assertEquals(20, $asset->meta('size')); + $this->assertFalse($asset->path('other.txt')->exists()); + } + #[Test] public function exists_uses_the_container_listing_when_it_is_already_loaded() { From 8cfc47c1d6df95bae2fd870fe28a84683cbf9371 Mon Sep 17 00:00:00 2001 From: edalzell Date: Thu, 15 Oct 2026 19:43:45 -0700 Subject: [PATCH 8/8] Memoize the asset disk existence check per path --- src/Assets/Asset.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index 360648d9b39..371bf01eda4 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -71,6 +71,7 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, Conta const VIDEO_EXTENSIONS = ['h264', 'mp4', 'm4v', 'ogv', 'webm', 'mov', 'mpeg', 'mpg', 'mkv']; protected $container; + protected $existsOnDisk; protected $path; protected $meta; protected $withEvents = true; @@ -227,9 +228,11 @@ public function exists() return false; } - return $this->container()->contents()->isLoaded() - ? $this->container()->files()->contains($path) - : $this->disk()->exists($path); + if ($this->container()->contents()->isLoaded()) { + return $this->container()->files()->contains($path); + } + + return $this->existsOnDisk ??= $this->disk()->exists($path); } public function getRawMeta() @@ -387,6 +390,8 @@ public function path($path = null) throw PathTraversalDetected::forPath($path); } + $this->existsOnDisk = null; + return $path; }) ->getter(function ($path) { @@ -708,6 +713,7 @@ public function delete() */ protected function clearCaches() { + $this->existsOnDisk = null; $this->meta = null; $this->cacheStore()->forget($this->metaCacheKey()); }