From 7fc9ceb4c6d082aa910a25876d401364c27fc67f Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 5 Nov 2026 16:33:48 +0000 Subject: [PATCH 1/4] [6.x] Don't keep two copies of every entry in memory while building the Stache While indexes are built, makeItemFromFile() keeps every entry it makes in Blink's `structure-entries`, and getCachedItem() then unserialises a second full copy of each one from the cache. On a cold warm of a large site this holds every entry twice. While an index is loading, hand out a shallow clone of the entry already in memory instead, clearing the same per-object caches that Entry::__sleep() drops. Callers still get their own object, and its data is only copied if they change it. Outside index loading nothing changes. --- src/Stache/Stores/CollectionEntriesStore.php | 32 +++++++++- tests/Data/Entries/EntryTest.php | 4 +- tests/Stache/Stores/EntriesStoreTest.php | 67 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index 09109f073da..653805701f9 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -2,7 +2,9 @@ namespace Statamic\Stache\Stores; +use Closure; use Illuminate\Support\Carbon; +use Statamic\Entries\Entry as EntryInstance; use Statamic\Entries\GetDateFromPath; use Statamic\Entries\GetSlugFromPath; use Statamic\Entries\GetSuffixFromPath; @@ -231,9 +233,7 @@ protected function writeItemToDisk($item) protected function getCachedItem($key) { - $cacheKey = $this->getItemCacheKey($key); - - if (! $entry = Stache::cacheStore()->get($cacheKey)) { + if (! $entry = $this->getEntryLoadedInThisRequest($key) ?? Stache::cacheStore()->get($this->getItemCacheKey($key))) { return null; } @@ -244,6 +244,32 @@ protected function getCachedItem($key) return $entry; } + /** + * While indexes are being built, every entry made from its file in this request is + * still in memory (makeItemFromFile() blinks it). Hand out a shallow clone of it + * rather than unserialising a second full copy from the cache: callers still get + * their own object, and its data is only copied if they change it. + */ + private function getEntryLoadedInThisRequest($key): ?EntryInstance + { + if (! Index::isLoading()) { + return null; + } + + if (! ($loaded = Blink::store('structure-entries')->get($key)) instanceof EntryInstance) { + return null; + } + + $entry = clone $loaded; + + // A copy read from the cache doesn't carry these (see Entry::__sleep). + Closure::bind(function () { + $this->cachedKeys = $this->computedCallbackCache = $this->siteCache = $this->augmentationReferenceKey = null; + }, $entry, EntryInstance::class)(); + + return $entry; + } + public function withoutBlinkingEntryUris($callback) { $this->shouldBlinkEntryUris = false; diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 5c277af174f..38c5ca15153 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -1562,10 +1562,12 @@ public function it_clears_blink_caches_when_saving() $mock->shouldReceive('store')->with('structure-uris')->once()->andReturn( $this->mock(\Spatie\Blink\Blink::class)->shouldReceive('forget')->with('a')->once()->getMock() ); - $mock->shouldReceive('store')->with('structure-entries')->twice()->andReturn( + $mock->shouldReceive('store')->with('structure-entries')->atLeast()->twice()->andReturn( tap($this->mock(\Spatie\Blink\Blink::class), function ($m) { $m->shouldReceive('forget')->with('a')->once(); $m->shouldReceive('put')->once(); + // Indexes loaded during the save may look for entries already in memory. + $m->shouldReceive('get')->andReturnNull(); }) ); diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 705a70f3c75..3804be37a44 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -7,10 +7,13 @@ use Mockery; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use ReflectionProperty; use Statamic\Contracts\Entries\Entry; use Statamic\Facades; +use Statamic\Facades\Blink; use Statamic\Facades\Path; use Statamic\Facades\Stache; +use Statamic\Stache\Indexes\Index; use Statamic\Stache\Stores\EntriesStore; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -361,4 +364,68 @@ public function it_ignores_entries_in_a_site_subdirectory_where_the_collection_d { $this->markTestIncomplete(); } + + #[Test] + public function while_an_index_loads_it_hands_out_a_clone_of_the_entry_built_in_this_request_instead_of_the_cached_copy() + { + $store = $this->parent->store('blog'); + $store->paths(); + + $inMemory = Blink::store('structure-entries')->get('blog-christmas'); + $this->putInItemCache($store, 'blog-christmas', (clone $inMemory)->set('title', 'From the cache')); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + + $this->assertNotSame($inMemory, $item); + $this->assertEquals('Christmas', $item->get('title')); + $this->assertEquals($inMemory->data()->all(), $item->data()->all()); + } + + #[Test] + public function changing_the_clone_does_not_change_the_entry_in_memory() + { + $store = $this->parent->store('blog'); + $store->paths(); + + $inMemory = Blink::store('structure-entries')->get('blog-christmas'); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + $item->set('title', 'Changed'); + + $this->assertEquals('Christmas', $inMemory->get('title')); + } + + #[Test] + public function outside_of_index_loading_it_still_reads_the_cached_copy() + { + $store = $this->parent->store('blog'); + $store->paths(); + + $inMemory = Blink::store('structure-entries')->get('blog-christmas'); + $this->putInItemCache($store, 'blog-christmas', (clone $inMemory)->set('title', 'From the cache')); + + $item = $store->getItem('blog-christmas'); + + $this->assertNotSame($inMemory, $item); + $this->assertEquals('From the cache', $item->get('title')); + } + + private function putInItemCache($store, $key, $item) + { + $cacheKey = (fn () => $this->getItemCacheKey($key))->call($store); + + Stache::cacheStore()->forever($cacheKey, $item); + } + + private function whileAnIndexLoads(callable $callback) + { + $stack = new ReflectionProperty(Index::class, 'loadingStack'); + $stack->setValue(null, ['entries::blog/test']); + + try { + return $callback(); + } finally { + $stack->setValue(null, []); + } + } } From 28357fb86658c7a178e691695f20239ef887434a Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 10 Nov 2026 13:44:52 -0500 Subject: [PATCH 2/4] Reuse a private snapshot instead of the structure-entries blink The structure-entries blink store can hold unsaved entries (anything passed to Page::setEntry(), or the instance handed out on a cache miss), so handing out clones of it while indexes load could cache unsaved values into indexes. The store now keeps its own copy of each entry it builds from a file, only serves it while it still matches what was cached, and gives each copy its own date, since Carbon is mutable. Co-Authored-By: Claude Opus 5.5 --- src/Stache/Stores/CollectionEntriesStore.php | 69 ++++++++++++++++---- tests/Data/Entries/EntryTest.php | 4 +- tests/Stache/Stores/EntriesStoreTest.php | 63 ++++++++++++++++++ 3 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index 653805701f9..1b554ca75e0 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -22,11 +22,13 @@ use Statamic\Support\Arr; use Statamic\Support\Str; use Symfony\Component\Finder\SplFileInfo; +use WeakReference; class CollectionEntriesStore extends ChildStore { protected $collection; private bool $shouldBlinkEntryUris = true; + private array $snapshots = []; protected function collection() { @@ -110,6 +112,8 @@ public function makeItemFromFile($path, $contents) $this->writeItemToDiskWithoutIncrementing($entry); } + $this->takeSnapshot($entry); + return $entry; } @@ -233,7 +237,7 @@ protected function writeItemToDisk($item) protected function getCachedItem($key) { - if (! $entry = $this->getEntryLoadedInThisRequest($key) ?? Stache::cacheStore()->get($this->getItemCacheKey($key))) { + if (! $entry = $this->getSnapshotCopy($key) ?? Stache::cacheStore()->get($this->getItemCacheKey($key))) { return null; } @@ -244,30 +248,73 @@ protected function getCachedItem($key) return $entry; } + protected function cacheItem($item) + { + parent::cacheItem($item); + + $key = $this->getItemKey($item); + $snapshot = $this->snapshots[$key] ?? null; + + // The first time the entry built from the file is cached, the cache holds exactly + // what the snapshot holds. Anything cached after that may differ from it. + if ($snapshot && ! $snapshot['cached'] && $snapshot['source']->get() === $item) { + $this->snapshots[$key]['cached'] = true; + } else { + unset($this->snapshots[$key]); + } + } + + public function forgetItem($key) + { + unset($this->snapshots[$key]); + + parent::forgetItem($key); + } + + private function takeSnapshot(EntryInstance $entry): void + { + $this->snapshots[$entry->id()] = [ + 'source' => WeakReference::create($entry), + 'snapshot' => $this->copy($entry), + 'cached' => false, + ]; + } + /** - * While indexes are being built, every entry made from its file in this request is - * still in memory (makeItemFromFile() blinks it). Hand out a shallow clone of it - * rather than unserialising a second full copy from the cache: callers still get - * their own object, and its data is only copied if they change it. + * While indexes are being built, hand out a copy of the snapshot taken when the entry + * was made from its file, rather than unserialising a second full copy from the cache. + * Its data arrays are shared with the snapshot until the caller changes them. */ - private function getEntryLoadedInThisRequest($key): ?EntryInstance + private function getSnapshotCopy($key): ?EntryInstance { if (! Index::isLoading()) { return null; } - if (! ($loaded = Blink::store('structure-entries')->get($key)) instanceof EntryInstance) { + $snapshot = $this->snapshots[$key] ?? null; + + if (! $snapshot || ! $snapshot['cached']) { return null; } - $entry = clone $loaded; + return $this->copy($snapshot['snapshot']); + } + + private function copy(EntryInstance $entry): EntryInstance + { + $copy = clone $entry; - // A copy read from the cache doesn't carry these (see Entry::__sleep). Closure::bind(function () { + // A copy read from the cache doesn't carry these (see Entry::__sleep). $this->cachedKeys = $this->computedCallbackCache = $this->siteCache = $this->augmentationReferenceKey = null; - }, $entry, EntryInstance::class)(); - return $entry; + // Carbon is mutable, so the copy needs its own date. + if (is_object($this->date)) { + $this->date = clone $this->date; + } + }, $copy, EntryInstance::class)(); + + return $copy; } public function withoutBlinkingEntryUris($callback) diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 38c5ca15153..5c277af174f 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -1562,12 +1562,10 @@ public function it_clears_blink_caches_when_saving() $mock->shouldReceive('store')->with('structure-uris')->once()->andReturn( $this->mock(\Spatie\Blink\Blink::class)->shouldReceive('forget')->with('a')->once()->getMock() ); - $mock->shouldReceive('store')->with('structure-entries')->atLeast()->twice()->andReturn( + $mock->shouldReceive('store')->with('structure-entries')->twice()->andReturn( tap($this->mock(\Spatie\Blink\Blink::class), function ($m) { $m->shouldReceive('forget')->with('a')->once(); $m->shouldReceive('put')->once(); - // Indexes loaded during the save may look for entries already in memory. - $m->shouldReceive('get')->andReturnNull(); }) ); diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 3804be37a44..edfeb55699c 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -410,6 +410,69 @@ public function outside_of_index_loading_it_still_reads_the_cached_copy() $this->assertEquals('From the cache', $item->get('title')); } + #[Test] + public function unsaved_changes_to_entries_in_memory_do_not_reach_indexes() + { + $this->useSerializingCacheStore(); + + $store = $this->parent->store('blog'); + $store->paths(); + + // The instance built from the file is handed out on a cache miss and can be changed without saving. + Blink::store('structure-entries')->get('blog-christmas')->set('title', 'Unsaved'); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + $this->assertEquals('Christmas', $item->get('title')); + + // Page::setEntry() puts whatever entry it is given into the same Blink store. + $unsaved = Facades\Entry::make()->id('blog-christmas')->collection('blog')->set('title', 'Also unsaved'); + Blink::store('structure-entries')->put('blog-christmas', $unsaved); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + $this->assertEquals('Christmas', $item->get('title')); + } + + #[Test] + public function changing_the_date_of_a_copy_does_not_change_later_copies() + { + $this->useSerializingCacheStore(); + + $store = $this->parent->store('blog'); + $store->paths(); + + $first = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + $original = $first->date()->toDateTimeString(); + $first->date()->addDay(); + + $second = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + + $this->assertEquals($original, $second->date()->toDateTimeString()); + } + + #[Test] + public function once_the_entry_is_cached_again_it_reads_the_cache() + { + $this->useSerializingCacheStore(); + + $store = $this->parent->store('blog'); + $store->paths(); + + $changed = $store->getItem('blog-christmas')->set('title', 'Saved'); + (fn () => $this->cacheItem($changed))->call($store); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + + $this->assertEquals('Saved', $item->get('title')); + } + + private function useSerializingCacheStore() + { + config([ + 'cache.stores.stache-serializing' => ['driver' => 'array', 'serialize' => true], + 'statamic.stache.cache_store' => 'stache-serializing', + ]); + } + private function putInItemCache($store, $key, $item) { $cacheKey = (fn () => $this->getItemCacheKey($key))->call($store); From f48130aff5d3929f3365bac6e8a1e7ea959e36e9 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 19:23:25 +0000 Subject: [PATCH 3/4] Keep entry snapshots in Blink so they only last for the request The snapshots were held on the store, which is created when the app boots and so is shared by every request in a long-running process (Octane). If another process saved an entry in between, an index built later in the same worker was built from the old snapshot and cached with the old value. Blink is new for each request, so the snapshots now live there. --- src/Stache/Stores/CollectionEntriesStore.php | 26 ++++++++++++++------ tests/Stache/Stores/EntriesStoreTest.php | 22 +++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index c6cbd97ab44..cb1fc5c0327 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -28,7 +28,6 @@ class CollectionEntriesStore extends ChildStore { protected $collection; private bool $shouldBlinkEntryUris = true; - private array $snapshots = []; private int $makingItemsInBulk = 0; private array $collectionsForBulk = []; @@ -298,31 +297,42 @@ protected function cacheItem($item) parent::cacheItem($item); $key = $this->getItemKey($item); - $snapshot = $this->snapshots[$key] ?? null; + $snapshot = $this->snapshots()->get($key); // The first time the entry built from the file is cached, the cache holds exactly // what the snapshot holds. Anything cached after that may differ from it. if ($snapshot && ! $snapshot['cached'] && $snapshot['source']->get() === $item) { - $this->snapshots[$key]['cached'] = true; + $snapshot['cached'] = true; + $this->snapshots()->put($key, $snapshot); } else { - unset($this->snapshots[$key]); + $this->snapshots()->forget($key); } } public function forgetItem($key) { - unset($this->snapshots[$key]); + $this->snapshots()->forget($key); parent::forgetItem($key); } private function takeSnapshot(EntryInstance $entry): void { - $this->snapshots[$entry->id()] = [ + $this->snapshots()->put($entry->id(), [ 'source' => WeakReference::create($entry), 'snapshot' => $this->copy($entry), 'cached' => false, - ]; + ]); + } + + /** + * Snapshots are only good for the request they were taken in, since another process can + * cache a different version of an entry at any time. This store can outlive a request + * (it does under Octane), but Blink is new for each one, so they are kept there. + */ + private function snapshots() + { + return Blink::store('entry-snapshots::'.$this->key()); } /** @@ -336,7 +346,7 @@ private function getSnapshotCopy($key): ?EntryInstance return null; } - $snapshot = $this->snapshots[$key] ?? null; + $snapshot = $this->snapshots()->get($key); if (! $snapshot || ! $snapshot['cached']) { return null; diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 61020474cc6..0ec002e964d 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -486,6 +486,28 @@ public function once_the_entry_is_cached_again_it_reads_the_cache() $this->assertEquals('Saved', $item->get('title')); } + #[Test] + public function snapshots_are_not_kept_for_a_later_request_in_the_same_process() + { + $this->useSerializingCacheStore(); + + // The first request in a long-running process builds the entries from their files. + $store = $this->parent->store('blog'); + $store->paths(); + + // Another process saves the entry, which this process's store never hears about. + $key = (fn () => $this->getItemCacheKey('blog-christmas'))->call($store); + $changed = Stache::cacheStore()->get($key)->set('title', 'Saved by another process'); + Stache::cacheStore()->forever($key, $changed); + + // The next request gets a new Blink (Octane clears resolved facades), but the same store. + Blink::swap(new \Statamic\Support\Blink); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + + $this->assertEquals('Saved by another process', $item->get('title')); + } + private function useSerializingCacheStore() { config([ From 56a4dfa77b9b74ac7de99d1686ab9c53082ecb84 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 19:02:55 +0000 Subject: [PATCH 4/4] Flush entry snapshots when a worker moves on to its next job --- src/Stache/Stores/CollectionEntriesStore.php | 9 ++++++++ tests/Stache/Stores/EntriesStoreTest.php | 23 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index cb1fc5c0327..1cf679b3f32 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -335,6 +335,15 @@ private function snapshots() return Blink::store('entry-snapshots::'.$this->key()); } + public function resetMemoizedState() + { + parent::resetMemoizedState(); + + // Blink isn't cleared between a worker's jobs, so the snapshots + // would otherwise last for as long as the worker does. + $this->snapshots()->flush(); + } + /** * While indexes are being built, hand out a copy of the snapshot taken when the entry * was made from its file, rather than unserialising a second full copy from the cache. diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 0ec002e964d..374d1baee2b 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -508,6 +508,29 @@ public function snapshots_are_not_kept_for_a_later_request_in_the_same_process() $this->assertEquals('Saved by another process', $item->get('title')); } + #[Test] + public function snapshots_are_not_kept_for_a_later_job_in_the_same_worker() + { + $this->useSerializingCacheStore(); + + // A job builds the entries from their files. + $store = $this->parent->store('blog'); + $store->paths(); + + // Another process saves the entry, which this process's store never hears about. + $key = (fn () => $this->getItemCacheKey('blog-christmas'))->call($store); + $changed = Stache::cacheStore()->get($key)->set('title', 'Saved by another process'); + Stache::cacheStore()->forever($key, $changed); + + // The worker moves on to its next job. Blink is left as it was. + Stache::stores()->each->resetMemoizedState(); + app('stache.indexes')->each->resetMemoizedState(); + + $item = $this->whileAnIndexLoads(fn () => $store->getItem('blog-christmas')); + + $this->assertEquals('Saved by another process', $item->get('title')); + } + private function useSerializingCacheStore() { config([