From 857b251aac7bd2a6d4c40b6a7674277dcb0856e9 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 23:59:20 +0000 Subject: [PATCH 1/2] Reset memoized store and index state when an Octane request arrives The stores are made when the app boots, so an Octane worker uses the same ones for every request. They were only told to forget what they'd memoized between queued jobs, so a worker never saw an entry that another process created or renamed. --- src/Stache/ServiceProvider.php | 18 +++++++++++++++++ tests/Stache/ServiceProviderTest.php | 30 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/Stache/ServiceProvider.php b/src/Stache/ServiceProvider.php index b54b619e676..1fa4fbe88f4 100644 --- a/src/Stache/ServiceProvider.php +++ b/src/Stache/ServiceProvider.php @@ -50,6 +50,8 @@ public function boot() $this->registerStores($stache); $this->resetMemoizedStateBetweenJobs($stache); + + $this->resetMemoizedStateBetweenOctaneRequests($stache); } private function registerStores($stache) @@ -88,6 +90,22 @@ private function resetMemoizedStateBetweenJobs($stache) }); } + /** + * The stores are made when the app boots, so under Octane a worker uses the same ones + * for every request it handles. What they remember (their paths, for one) has to be + * read again for each request, or the worker never sees what another process saves. + */ + private function resetMemoizedStateBetweenOctaneRequests($stache) + { + // Listened for by name, since Octane isn't necessarily installed. + Event::listen('Laravel\Octane\Events\RequestReceived', function () use ($stache) { + $stache->stores()->each->resetMemoizedState(); + + // Octane handles each request in its own copy of the container. + app('stache.indexes')->each->resetMemoizedState(); + }); + } + private function locks() { if (config('statamic.stache.lock.enabled', true)) { diff --git a/tests/Stache/ServiceProviderTest.php b/tests/Stache/ServiceProviderTest.php index f9c882732c1..23961f42b87 100644 --- a/tests/Stache/ServiceProviderTest.php +++ b/tests/Stache/ServiceProviderTest.php @@ -56,6 +56,36 @@ public function it_resets_memoized_store_and_index_state_between_jobs_when_runni $this->assertEquals(2, $hits->filter(fn ($key) => $key === $idIndexCacheKey)->count()); } + #[Test] + public function it_resets_memoized_store_and_index_state_when_an_octane_request_arrives() + { + $store = (new ServiceProviderTestStore)->directory('/path/to/directory'); + Stache::registerStore($store); + + $pathsCacheKey = "stache::indexes::{$store->key()}::path"; + $idIndexCacheKey = "stache::indexes::{$store->key()}::id"; + + Cache::put($pathsCacheKey, ['alfa' => 'alfa.md']); + Cache::put($idIndexCacheKey, ['alfa' => 'alfa']); + + // A request to a worker uses the store, which is the same one for every request it handles. + $this->assertEquals(['alfa'], $store->paths()->keys()->all()); + $this->assertEquals(['alfa'], $store->index('id')->keys()); + + // Another process saves an item. + Cache::put($pathsCacheKey, ['alfa' => 'alfa.md', 'bravo' => 'bravo.md']); + Cache::put($idIndexCacheKey, ['alfa' => 'alfa', 'bravo' => 'bravo']); + + $this->assertEquals(['alfa'], $store->paths()->keys()->all()); + $this->assertEquals(['alfa'], $store->index('id')->keys()); + + // Octane isn't a dependency, so its event is fired by name, as the listener is registered. + Event::dispatch('Laravel\Octane\Events\RequestReceived'); + + $this->assertEquals(['alfa', 'bravo'], $store->paths()->keys()->all()); + $this->assertEquals(['alfa', 'bravo'], $store->index('id')->keys()); + } + #[Test] public function it_does_not_reset_memoized_state_for_jobs_processed_outside_a_worker() { From 2018497421525bb7d0526c144fc789c660f7e552 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 12 Nov 2026 00:10:43 +0000 Subject: [PATCH 2/2] Run the checks again