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, []); + } + } }