From a6e737b5cce855f151a4f4ba53cb34a7971a7fdb Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 10:38:34 -0700 Subject: [PATCH 01/12] wip --- src/Stache/Indexes/Index.php | 7 +++++ src/Stache/Indexes/Terms/Associations.php | 38 ++++++++++++++++++++--- src/Stache/Stache.php | 3 +- src/Stache/Stores/AggregateStore.php | 10 ++++++ src/Stache/Stores/Store.php | 32 ++++++++++++++++--- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/Stache/Indexes/Index.php b/src/Stache/Indexes/Index.php index 7e437258e70..28e03ada43d 100644 --- a/src/Stache/Indexes/Index.php +++ b/src/Stache/Indexes/Index.php @@ -58,6 +58,13 @@ public function push($value) $this->items[] = $value; } + public function setItems(array $items): static + { + $this->items = $items; + + return $this; + } + public function load() { if ($this->loaded) { diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 1ddef3781e3..1737d5d2e01 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -2,6 +2,7 @@ namespace Statamic\Stache\Indexes\Terms; +use Statamic\Facades\Stache; use Statamic\Facades\Taxonomy; use Statamic\Stache\Indexes\Index; use Statamic\Support\Str; @@ -13,6 +14,35 @@ public function getItems() return Taxonomy::findByHandle($handle = $this->store->childKey()) ->collections() ->flatMap(function ($collection) use ($handle) { + $entriesStore = Stache::store('entries')->store($collection->handle()); + $collectionHandle = $collection->handle(); + + // Fast path: warmValueIndexes() already built entries' category index in Redis. + $storeKey = $entriesStore->key(); + $cacheKey = "stache::indexes::{$storeKey}::{$handle}"; + $taxData = Stache::cacheStore()->get($cacheKey); + + if ($taxData !== null) { + $taxValues = collect($taxData)->filter(fn ($v) => ! empty($v)); + $siteData = Stache::cacheStore()->get("stache::indexes::{$storeKey}::site"); + $sites = $siteData !== null ? collect($siteData) : null; + + return $taxValues->flatMap(function ($value, $entryId) use ($collectionHandle, $entriesStore, $sites) { + $site = $sites !== null + ? $sites->get($entryId) + : $entriesStore->getItem($entryId)?->locale(); + + return collect((array) $value)->map(fn ($v) => [ + 'value' => $v, + 'slug' => Str::slug($v), + 'entry' => $entryId, + 'collection' => $collectionHandle, + 'site' => $site, + ]); + }); + } + + // Cold path fallback (fires outside of a 2-pass warm, e.g. in tests or direct calls). return $collection->queryEntries() ->where($handle, '<>', null) ->get() @@ -20,11 +50,11 @@ public function getItems() return collect($entry->value($handle)) ->map(function ($value) use ($entry) { return [ - 'value' => $value, - 'slug' => Str::slug($value), - 'entry' => $entry->id(), + 'value' => $value, + 'slug' => Str::slug($value), + 'entry' => $entry->id(), 'collection' => $entry->collectionHandle(), - 'site' => $entry->locale(), + 'site' => $entry->locale(), ]; }); })->all(); diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 1b25b25591c..4cf8cb7ad8a 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -137,7 +137,8 @@ public function warm() if ($this->shouldUseParallelWarming($stores)) { $this->warmInParallel($stores); } else { - $stores->each->warm(); + $stores->each->warmValueIndexes(); + $stores->each->warmOtherIndexes(); } $this->stopTimer(); diff --git a/src/Stache/Stores/AggregateStore.php b/src/Stache/Stores/AggregateStore.php index df641e96186..5553dbd0994 100644 --- a/src/Stache/Stores/AggregateStore.php +++ b/src/Stache/Stores/AggregateStore.php @@ -94,6 +94,16 @@ public function resetMemoizedState() $this->stores->each->resetMemoizedState(); } + public function warmValueIndexes() + { + $this->discoverStores()->each->warmValueIndexes(); + } + + public function warmOtherIndexes() + { + $this->discoverStores()->each->warmOtherIndexes(); + } + public function paths() { return $this->discoverStores()->flatMap(function ($store) { diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index b9b067a76a6..0459f183489 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -410,12 +410,36 @@ public function clear() public function warm() { - $this->shouldCacheFileItems = true; + $this->warmValueIndexes(); + $this->warmOtherIndexes(); + } + + public function warmValueIndexes() + { + $valueIndexes = $this->resolveIndexes()->filter( + fn ($index) => method_exists($index, 'getItemValue') + ); - $this->resolveIndexes()->each->update(); + $accumulated = $valueIndexes->map(fn () => [])->all(); - $this->shouldCacheFileItems = false; - $this->fileItems = null; + foreach ($this->paths()->keys() as $key) { + $item = $this->getItem($key); + + foreach ($valueIndexes as $name => $index) { + $accumulated[$name][$key] = $index->getItemValue($item); + } + } + + $valueIndexes->each(function ($index, $name) use ($accumulated) { + $index->setItems($accumulated[$name])->cache(); + }); + } + + public function warmOtherIndexes() + { + $this->resolveIndexes() + ->filter(fn ($index) => ! method_exists($index, 'getItemValue')) + ->each->update(); } public function keys() From 0017e4eb956c4e7f5f1886d9aeec12081e44bdad Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 10:41:24 -0700 Subject: [PATCH 02/12] comments --- src/Stache/Indexes/Index.php | 4 ++++ src/Stache/Indexes/Terms/Associations.php | 21 +++++++++++++++++---- src/Stache/Stache.php | 4 ++++ src/Stache/Stores/AggregateStore.php | 2 ++ src/Stache/Stores/Store.php | 11 +++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/Stache/Indexes/Index.php b/src/Stache/Indexes/Index.php index 28e03ada43d..3b98ab6cf9b 100644 --- a/src/Stache/Indexes/Index.php +++ b/src/Stache/Indexes/Index.php @@ -58,6 +58,10 @@ public function push($value) $this->items[] = $value; } + /** + * Bulk-sets the index items without going through getItems(). Used by + * Store::warmValueIndexes() to write pre-accumulated values in one shot. + */ public function setItems(array $items): static { $this->items = $items; diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 1737d5d2e01..bb04132995a 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -9,6 +9,20 @@ class Associations extends Index { + /** + * Builds the term→entry association map for a taxonomy. + * + * This index is the reason warm() runs in two passes. Associations needs to know + * which entries reference each term, but the only way to find that (without loading + * every Entry from disk) is to read the entries' already-warmed taxonomy index from + * Redis. The 2-pass warm guarantees that index exists before this method is called. + * + * Fast path (used during stache:warm): reads the flat `[entryId => termValue]` and + * `[entryId => site]` arrays directly from Redis — no Entry objects are constructed. + * + * Cold path (used outside of stache:warm, e.g. on-demand index builds): queries + * entries via Eloquent as before. Slower but always correct. + */ public function getItems() { return Taxonomy::findByHandle($handle = $this->store->childKey()) @@ -17,12 +31,11 @@ public function getItems() $entriesStore = Stache::store('entries')->store($collection->handle()); $collectionHandle = $collection->handle(); - // Fast path: warmValueIndexes() already built entries' category index in Redis. $storeKey = $entriesStore->key(); - $cacheKey = "stache::indexes::{$storeKey}::{$handle}"; - $taxData = Stache::cacheStore()->get($cacheKey); + $taxData = Stache::cacheStore()->get("stache::indexes::{$storeKey}::{$handle}"); if ($taxData !== null) { + // Fast path: entries' value indexes are already in Redis (Pass 1 ran first). $taxValues = collect($taxData)->filter(fn ($v) => ! empty($v)); $siteData = Stache::cacheStore()->get("stache::indexes::{$storeKey}::site"); $sites = $siteData !== null ? collect($siteData) : null; @@ -42,7 +55,7 @@ public function getItems() }); } - // Cold path fallback (fires outside of a 2-pass warm, e.g. in tests or direct calls). + // Cold path: Redis miss — fall back to querying entries directly. return $collection->queryEntries() ->where($handle, '<>', null) ->get() diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 4cf8cb7ad8a..6e291fb8147 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -137,6 +137,10 @@ public function warm() if ($this->shouldUseParallelWarming($stores)) { $this->warmInParallel($stores); } else { + // Two-pass warm: Pass 1 writes all Value indexes (including entries' taxonomy + // indexes) to Redis across every store before Pass 2 runs. This lets + // Terms\Associations read from Redis in Pass 2 instead of loading all Entry + // objects from disk, which was the main source of slow warm times. $stores->each->warmValueIndexes(); $stores->each->warmOtherIndexes(); } diff --git a/src/Stache/Stores/AggregateStore.php b/src/Stache/Stores/AggregateStore.php index 5553dbd0994..28d17d3e484 100644 --- a/src/Stache/Stores/AggregateStore.php +++ b/src/Stache/Stores/AggregateStore.php @@ -94,11 +94,13 @@ public function resetMemoizedState() $this->stores->each->resetMemoizedState(); } + /** @see Store::warmValueIndexes() */ public function warmValueIndexes() { $this->discoverStores()->each->warmValueIndexes(); } + /** @see Store::warmOtherIndexes() */ public function warmOtherIndexes() { $this->discoverStores()->each->warmOtherIndexes(); diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 0459f183489..ad623e5de72 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -414,6 +414,12 @@ public function warm() $this->warmOtherIndexes(); } + /** + * Pass 1 of the 2-pass warm. Loads every file once and accumulates values for + * all Value-based indexes in a single loop, then writes each index to Redis in + * one shot. This ensures entries' taxonomy indexes (e.g. `categories`) are in + * Redis before Pass 2 runs, so Terms\Associations can use the fast path. + */ public function warmValueIndexes() { $valueIndexes = $this->resolveIndexes()->filter( @@ -435,6 +441,11 @@ public function warmValueIndexes() }); } + /** + * Pass 2 of the 2-pass warm. Runs after all stores have completed Pass 1, so + * non-Value indexes (e.g. Terms\Associations) can read from Redis instead of + * loading Entry objects from disk. + */ public function warmOtherIndexes() { $this->resolveIndexes() From 761fa55bae8b1e888a1334ba685b7a954b1196d2 Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 11:07:18 -0700 Subject: [PATCH 03/12] pint --- src/Stache/Indexes/Terms/Associations.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index bb04132995a..4bb5e1eba0f 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -46,11 +46,11 @@ public function getItems() : $entriesStore->getItem($entryId)?->locale(); return collect((array) $value)->map(fn ($v) => [ - 'value' => $v, - 'slug' => Str::slug($v), - 'entry' => $entryId, + 'value' => $v, + 'slug' => Str::slug($v), + 'entry' => $entryId, 'collection' => $collectionHandle, - 'site' => $site, + 'site' => $site, ]); }); } @@ -63,11 +63,11 @@ public function getItems() return collect($entry->value($handle)) ->map(function ($value) use ($entry) { return [ - 'value' => $value, - 'slug' => Str::slug($value), - 'entry' => $entry->id(), + 'value' => $value, + 'slug' => Str::slug($value), + 'entry' => $entry->id(), 'collection' => $entry->collectionHandle(), - 'site' => $entry->locale(), + 'site' => $entry->locale(), ]; }); })->all(); From 55c330646891fa6ebdbdd33b333704fae9a9dc2f Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 12:26:10 -0700 Subject: [PATCH 04/12] tidy --- src/Stache/Indexes/Terms/Associations.php | 42 ++++++++++++----------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 4bb5e1eba0f..f3cb999fee3 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -15,10 +15,10 @@ class Associations extends Index * This index is the reason warm() runs in two passes. Associations needs to know * which entries reference each term, but the only way to find that (without loading * every Entry from disk) is to read the entries' already-warmed taxonomy index from - * Redis. The 2-pass warm guarantees that index exists before this method is called. + * the cache. The 2-pass warm guarantees that index exists before this method is called. * * Fast path (used during stache:warm): reads the flat `[entryId => termValue]` and - * `[entryId => site]` arrays directly from Redis — no Entry objects are constructed. + * `[entryId => site]` arrays directly from the cache — no Entry objects are constructed. * * Cold path (used outside of stache:warm, e.g. on-demand index builds): queries * entries via Eloquent as before. Slower but always correct. @@ -32,27 +32,29 @@ public function getItems() $collectionHandle = $collection->handle(); $storeKey = $entriesStore->key(); - $taxData = Stache::cacheStore()->get("stache::indexes::{$storeKey}::{$handle}"); - if ($taxData !== null) { - // Fast path: entries' value indexes are already in Redis (Pass 1 ran first). - $taxValues = collect($taxData)->filter(fn ($v) => ! empty($v)); - $siteData = Stache::cacheStore()->get("stache::indexes::{$storeKey}::site"); - $sites = $siteData !== null ? collect($siteData) : null; + // array of [entry_id => [term]] + $taxonomyData = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::{$handle}")); - return $taxValues->flatMap(function ($value, $entryId) use ($collectionHandle, $entriesStore, $sites) { - $site = $sites !== null - ? $sites->get($entryId) - : $entriesStore->getItem($entryId)?->locale(); + if (! is_null($taxonomyData)) { + // Fast path: entries' value indexes are already in the cache (Pass 1 ran first). + $sites = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::site")); - return collect((array) $value)->map(fn ($v) => [ - 'value' => $v, - 'slug' => Str::slug($v), - 'entry' => $entryId, - 'collection' => $collectionHandle, - 'site' => $site, - ]); - }); + return $taxonomyData + ->filter(fn (?array $entryTerms) => ! empty($entryTerms)) + ->flatMap(function ($value, $entryId) use ($collectionHandle, $entriesStore, $sites) { + $site = $sites->isNotEmpty() + ? $sites->get($entryId) + : $entriesStore->getItem($entryId)?->locale(); + + return collect((array) $value)->map(fn ($v) => [ + 'value' => $v, + 'slug' => Str::slug($v), + 'entry' => $entryId, + 'collection' => $collectionHandle, + 'site' => $site, + ]); + }); } // Cold path: Redis miss — fall back to querying entries directly. From bd27f5e4ed759973523bb1e2680676c5a218ce80 Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 12:45:10 -0700 Subject: [PATCH 05/12] more tidy --- src/Stache/Indexes/Terms/Associations.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index f3cb999fee3..8c23142828d 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -36,20 +36,20 @@ public function getItems() // array of [entry_id => [term]] $taxonomyData = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::{$handle}")); - if (! is_null($taxonomyData)) { + if ($taxonomyData->isNotEmpty()) { // Fast path: entries' value indexes are already in the cache (Pass 1 ran first). $sites = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::site")); return $taxonomyData - ->filter(fn (?array $entryTerms) => ! empty($entryTerms)) - ->flatMap(function ($value, $entryId) use ($collectionHandle, $entriesStore, $sites) { + ->filter(fn (?array $entryTerms): bool => ! empty($entryTerms)) + ->flatMap(function (array $terms, string|int $entryId) use ($collectionHandle, $entriesStore, $sites) { $site = $sites->isNotEmpty() ? $sites->get($entryId) : $entriesStore->getItem($entryId)?->locale(); - return collect((array) $value)->map(fn ($v) => [ - 'value' => $v, - 'slug' => Str::slug($v), + return collect($terms)->map(fn (string $term) => [ + 'value' => $term, + 'slug' => Str::slug($term), 'entry' => $entryId, 'collection' => $collectionHandle, 'site' => $site, From d8bfd2fc065650cbe86049dda017f0cba1bb72f2 Mon Sep 17 00:00:00 2001 From: edalzell Date: Sat, 13 Jun 2026 12:49:56 -0700 Subject: [PATCH 06/12] tidy & comment --- src/Stache/Stores/Store.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index ad623e5de72..a54bf46c52b 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -422,10 +422,18 @@ public function warm() */ public function warmValueIndexes() { - $valueIndexes = $this->resolveIndexes()->filter( - fn ($index) => method_exists($index, 'getItemValue') - ); - + $valueIndexes = $this + ->resolveIndexes() + ->filter( + fn ($index) => method_exists($index, 'getItemValue') + ); + + /* + This sets up a structure like ['fieldName' => [], 'anotherField' => []] before the loop below + it iterates over every item in the store. Each inner array then gets populated with $key => $value + pairs as items are processed, so all items are batched by index rather than writing to cache one at a time. + It's a performance optimization — collect everything first, then flush each index to cache in one shot on line 449. + */ $accumulated = $valueIndexes->map(fn () => [])->all(); foreach ($this->paths()->keys() as $key) { From 4d1034795df701fd5fd157079759e1cdbd312936 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 2 Sep 2026 10:34:01 -0700 Subject: [PATCH 07/12] Build term associations from entry indexes, handling max_items: 1 --- src/Stache/Indexes/Terms/Associations.php | 75 ++++++----------------- tests/Stache/StacheTest.php | 31 ++++++++++ 2 files changed, 50 insertions(+), 56 deletions(-) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 8c23142828d..8c6977f1a20 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -5,74 +5,37 @@ use Statamic\Facades\Stache; use Statamic\Facades\Taxonomy; use Statamic\Stache\Indexes\Index; +use Statamic\Support\Arr; use Statamic\Support\Str; class Associations extends Index { /** - * Builds the term→entry association map for a taxonomy. + * Builds the term→entry association map for a taxonomy from the entries + * stores' value indexes rather than by loading every Entry from disk. * - * This index is the reason warm() runs in two passes. Associations needs to know - * which entries reference each term, but the only way to find that (without loading - * every Entry from disk) is to read the entries' already-warmed taxonomy index from - * the cache. The 2-pass warm guarantees that index exists before this method is called. - * - * Fast path (used during stache:warm): reads the flat `[entryId => termValue]` and - * `[entryId => site]` arrays directly from the cache — no Entry objects are constructed. - * - * Cold path (used outside of stache:warm, e.g. on-demand index builds): queries - * entries via Eloquent as before. Slower but always correct. + * During stache:warm those indexes are already cached (Pass 1 runs before + * this index is built in Pass 2). Outside of warming they'll be built on + * demand, which costs the same as the old query-based approach did. */ public function getItems() { return Taxonomy::findByHandle($handle = $this->store->childKey()) ->collections() ->flatMap(function ($collection) use ($handle) { - $entriesStore = Stache::store('entries')->store($collection->handle()); - $collectionHandle = $collection->handle(); - - $storeKey = $entriesStore->key(); - - // array of [entry_id => [term]] - $taxonomyData = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::{$handle}")); - - if ($taxonomyData->isNotEmpty()) { - // Fast path: entries' value indexes are already in the cache (Pass 1 ran first). - $sites = collect(Stache::cacheStore()->get("stache::indexes::{$storeKey}::site")); - - return $taxonomyData - ->filter(fn (?array $entryTerms): bool => ! empty($entryTerms)) - ->flatMap(function (array $terms, string|int $entryId) use ($collectionHandle, $entriesStore, $sites) { - $site = $sites->isNotEmpty() - ? $sites->get($entryId) - : $entriesStore->getItem($entryId)?->locale(); - - return collect($terms)->map(fn (string $term) => [ - 'value' => $term, - 'slug' => Str::slug($term), - 'entry' => $entryId, - 'collection' => $collectionHandle, - 'site' => $site, - ]); - }); - } - - // Cold path: Redis miss — fall back to querying entries directly. - return $collection->queryEntries() - ->where($handle, '<>', null) - ->get() - ->flatMap(function ($entry) use ($handle) { - return collect($entry->value($handle)) - ->map(function ($value) use ($entry) { - return [ - 'value' => $value, - 'slug' => Str::slug($value), - 'entry' => $entry->id(), - 'collection' => $entry->collectionHandle(), - 'site' => $entry->locale(), - ]; - }); - })->all(); + $entries = Stache::store('entries')->store($collection->handle()); + $ids = $entries->index('id'); + $sites = $entries->index('site'); + + return $entries->index($handle)->items() + ->filter() + ->flatMap(fn ($terms, $key) => collect(Arr::wrap($terms))->map(fn ($term) => [ + 'value' => $term, + 'slug' => Str::slug($term), + 'entry' => $ids->get($key), + 'collection' => $collection->handle(), + 'site' => $sites->get($key), + ])); })->all(); } diff --git a/tests/Stache/StacheTest.php b/tests/Stache/StacheTest.php index 1a6306e8930..24f1eaeadb1 100644 --- a/tests/Stache/StacheTest.php +++ b/tests/Stache/StacheTest.php @@ -2,21 +2,30 @@ namespace Tests\Stache; +use Facades\Tests\Factories\EntryFactory; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Blink; +use Statamic\Facades\Blueprint; +use Statamic\Facades\Collection as CollectionFacade; +use Statamic\Facades\Stache as StacheFacade; +use Statamic\Facades\Taxonomy; +use Statamic\Facades\Term; use Statamic\Stache\NullLockStore; use Statamic\Stache\Stache; use Statamic\Stache\Stores\ChildStore; use Statamic\Stache\Stores\CollectionsStore; use Statamic\Stache\Stores\EntriesStore; use Symfony\Component\Lock\LockFactory; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class StacheTest extends TestCase { + use PreventSavingStacheItemsToDisk; + protected $stache; public function setUp(): void @@ -195,4 +204,26 @@ public static function watcherProvider() ['production', 'config' => null, 'expected' => false], ]; } + + #[Test] + public function warming_builds_term_associations_for_single_item_taxonomy_fields() + { + Taxonomy::make('tags')->save(); + CollectionFacade::make('blog')->taxonomies(['tags'])->save(); + Blueprint::make('blog')->setNamespace('collections.blog')->setContents(['fields' => [ + ['handle' => 'tags', 'field' => ['type' => 'terms', 'taxonomies' => ['tags'], 'max_items' => 1]], + ]])->save(); + Term::make('alfa')->taxonomy('tags')->data(['title' => 'Alfa'])->save(); + EntryFactory::collection('blog')->id('1')->slug('one')->data(['tags' => 'alfa'])->create(); + EntryFactory::collection('blog')->id('2')->slug('two')->data(['tags' => 'bravo'])->create(); + EntryFactory::collection('blog')->id('3')->slug('three')->data([])->create(); + + StacheFacade::clear(); + StacheFacade::warm(); + + $this->assertEquals([ + ['value' => 'alfa', 'slug' => 'alfa', 'entry' => '1', 'collection' => 'blog', 'site' => 'en'], + ['value' => 'bravo', 'slug' => 'bravo', 'entry' => '2', 'collection' => 'blog', 'site' => 'en'], + ], StacheFacade::store('terms')->store('tags')->index('associations')->items()->all()); + } } From 476d590165e89fa192349c48d2c53ef0a3cda3be Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 2 Sep 2026 10:34:01 -0700 Subject: [PATCH 08/12] Keep indexes that override getItems() in warm pass 2 --- src/Stache/Stache.php | 8 +++---- src/Stache/Stores/Store.php | 42 +++++++++++++++++++++---------------- tests/Stache/StacheTest.php | 22 +++++++++++++++++++ 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 6e291fb8147..72f5a24b49e 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -137,10 +137,10 @@ public function warm() if ($this->shouldUseParallelWarming($stores)) { $this->warmInParallel($stores); } else { - // Two-pass warm: Pass 1 writes all Value indexes (including entries' taxonomy - // indexes) to Redis across every store before Pass 2 runs. This lets - // Terms\Associations read from Redis in Pass 2 instead of loading all Entry - // objects from disk, which was the main source of slow warm times. + // Two-pass warm: Pass 1 caches all per-item value indexes (including entries' + // taxonomy indexes) across every store before Pass 2 runs. This lets + // Terms\Associations read from the cache in Pass 2 instead of loading all + // Entry objects from disk, which was the main source of slow warm times. $stores->each->warmValueIndexes(); $stores->each->warmOtherIndexes(); } diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index a54bf46c52b..f3607a1b23f 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -416,24 +416,14 @@ public function warm() /** * Pass 1 of the 2-pass warm. Loads every file once and accumulates values for - * all Value-based indexes in a single loop, then writes each index to Redis in - * one shot. This ensures entries' taxonomy indexes (e.g. `categories`) are in - * Redis before Pass 2 runs, so Terms\Associations can use the fast path. + * all per-item value indexes in a single loop, then writes each index to the + * cache in one shot. This ensures entries' taxonomy indexes (e.g. `categories`) + * are cached before Pass 2 runs, so Terms\Associations can build from them. */ public function warmValueIndexes() { - $valueIndexes = $this - ->resolveIndexes() - ->filter( - fn ($index) => method_exists($index, 'getItemValue') - ); - - /* - This sets up a structure like ['fieldName' => [], 'anotherField' => []] before the loop below - it iterates over every item in the store. Each inner array then gets populated with $key => $value - pairs as items are processed, so all items are batched by index rather than writing to cache one at a time. - It's a performance optimization — collect everything first, then flush each index to cache in one shot on line 449. - */ + $valueIndexes = $this->resolveIndexes()->filter(fn ($index) => $this->isPerItemValueIndex($index)); + $accumulated = $valueIndexes->map(fn () => [])->all(); foreach ($this->paths()->keys() as $key) { @@ -451,14 +441,19 @@ public function warmValueIndexes() /** * Pass 2 of the 2-pass warm. Runs after all stores have completed Pass 1, so - * non-Value indexes (e.g. Terms\Associations) can read from Redis instead of - * loading Entry objects from disk. + * indexes that depend on other indexes (e.g. Terms\Associations) can read + * them from the cache instead of loading Entry objects from disk. */ public function warmOtherIndexes() { + $this->shouldCacheFileItems = true; + $this->resolveIndexes() - ->filter(fn ($index) => ! method_exists($index, 'getItemValue')) + ->reject(fn ($index) => $this->isPerItemValueIndex($index)) ->each->update(); + + $this->shouldCacheFileItems = false; + $this->fileItems = null; } public function keys() @@ -469,4 +464,15 @@ public function keys() return $this->keys = (new Keys($this))->load(); } + + /** + * Whether an index can be built purely by mapping getItemValue() over each item. + * Subclasses of Value that override getItems() (e.g. Terms\Value, which merges in + * on-the-fly terms) need their own build logic and belong in Pass 2. + */ + private function isPerItemValueIndex(Index $index): bool + { + return $index instanceof Indexes\Value + && (new \ReflectionMethod($index, 'getItems'))->getDeclaringClass()->getName() === Indexes\Value::class; + } } diff --git a/tests/Stache/StacheTest.php b/tests/Stache/StacheTest.php index 24f1eaeadb1..dcd57dbc1d2 100644 --- a/tests/Stache/StacheTest.php +++ b/tests/Stache/StacheTest.php @@ -226,4 +226,26 @@ public function warming_builds_term_associations_for_single_item_taxonomy_fields ['value' => 'bravo', 'slug' => 'bravo', 'entry' => '2', 'collection' => 'blog', 'site' => 'en'], ], StacheFacade::store('terms')->store('tags')->index('associations')->items()->all()); } + + #[Test] + public function warming_includes_on_the_fly_terms_in_term_value_indexes() + { + Taxonomy::make('tags')->save(); + CollectionFacade::make('blog')->taxonomies(['tags'])->save(); + Term::make('alfa')->taxonomy('tags')->data(['title' => 'Alfa'])->save(); + EntryFactory::collection('blog')->id('1')->slug('one')->data(['tags' => ['alfa', 'bravo']])->create(); + + StacheFacade::clear(); + StacheFacade::warm(); + + $store = StacheFacade::store('terms')->store('tags'); + + $this->assertEquals([ + ['value' => 'alfa', 'slug' => 'alfa', 'entry' => '1', 'collection' => 'blog', 'site' => 'en'], + ['value' => 'bravo', 'slug' => 'bravo', 'entry' => '1', 'collection' => 'blog', 'site' => 'en'], + ], $store->index('associations')->items()->all()); + + $this->assertEquals(['en::alfa' => 'alfa', 'en::bravo' => 'bravo'], $store->index('slug')->items()->all()); + $this->assertEquals(['en::alfa' => 'en', 'en::bravo' => 'en'], $store->index('site')->items()->all()); + } } From 33909e9f4229abdfbcd45649dac6efefbcd59967 Mon Sep 17 00:00:00 2001 From: Erin Dalzell Date: Mon, 14 Sep 2026 12:12:45 -0700 Subject: [PATCH 09/12] Update src/Stache/Stores/Store.php Co-authored-by: Jason Varga --- src/Stache/Stores/Store.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index f3607a1b23f..97d36b4f99d 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -427,7 +427,9 @@ public function warmValueIndexes() $accumulated = $valueIndexes->map(fn () => [])->all(); foreach ($this->paths()->keys() as $key) { - $item = $this->getItem($key); + if (! $item = $this->getItem($key)) { + continue; + } foreach ($valueIndexes as $name => $index) { $accumulated[$name][$key] = $index->getItemValue($item); From 4fc1b90844a5046212152f623ae34b9db72f67d5 Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 14 Sep 2026 12:24:30 -0700 Subject: [PATCH 10/12] Skip pass 1 for stores with no per-item value indexes --- src/Stache/Stores/Store.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 97d36b4f99d..56afc8cd811 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -424,6 +424,10 @@ public function warmValueIndexes() { $valueIndexes = $this->resolveIndexes()->filter(fn ($index) => $this->isPerItemValueIndex($index)); + if ($valueIndexes->isEmpty()) { + return; + } + $accumulated = $valueIndexes->map(fn () => [])->all(); foreach ($this->paths()->keys() as $key) { From b27b06bfb72f51e06cab1d0580151d0a53b57288 Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 14 Sep 2026 12:24:44 -0700 Subject: [PATCH 11/12] Keep honouring warm() overrides in stores that define one --- src/Stache/Stache.php | 32 +++++++++++++++++++++------- src/Stache/Stores/AggregateStore.php | 10 +++++++++ src/Stache/Stores/Store.php | 10 +++++++++ tests/Stache/StacheTest.php | 31 +++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 72f5a24b49e..1c8e1326758 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -137,12 +137,7 @@ public function warm() if ($this->shouldUseParallelWarming($stores)) { $this->warmInParallel($stores); } else { - // Two-pass warm: Pass 1 caches all per-item value indexes (including entries' - // taxonomy indexes) across every store before Pass 2 runs. This lets - // Terms\Associations read from the cache in Pass 2 instead of loading all - // Entry objects from disk, which was the main source of slow warm times. - $stores->each->warmValueIndexes(); - $stores->each->warmOtherIndexes(); + $this->warmSequentially($stores); } $this->stopTimer(); @@ -285,6 +280,25 @@ protected function shouldUseParallelWarming($stores): bool return true; } + /** + * Two-pass warm: Pass 1 caches all per-item value indexes (including entries' + * taxonomy indexes) across every store before Pass 2 runs. This lets + * Terms\Associations read from the cache in Pass 2 instead of loading all + * Entry objects from disk, which was the main source of slow warm times. + * + * Stores that define their own warm() are warmed through it instead, so + * third-party overrides aren't silently bypassed. + */ + protected function warmSequentially($stores) + { + [$overriding, $twoPass] = $stores->partition->overridesWarm(); + + $overriding->each->warm(); + + $twoPass->each->warmValueIndexes(); + $twoPass->each->warmOtherIndexes(); + } + protected function warmInParallel($stores) { try { @@ -302,7 +316,9 @@ protected function warmInParallel($stores) $closures = $chunks->map(function ($chunk) { return function () use ($chunk) { - return $chunk->each->warm()->keys()->all(); + $this->warmSequentially($chunk); + + return $chunk->keys()->all(); }; })->all(); @@ -315,7 +331,7 @@ protected function warmInParallel($stores) Concurrency::driver($driver)->run($closures); } catch (\Exception $e) { Log::warning('Parallel warming failed, falling back to sequential: '.$e->getMessage()); - $stores->each->warm(); + $this->warmSequentially($stores); } } diff --git a/src/Stache/Stores/AggregateStore.php b/src/Stache/Stores/AggregateStore.php index 28d17d3e484..13cc2fb0741 100644 --- a/src/Stache/Stores/AggregateStore.php +++ b/src/Stache/Stores/AggregateStore.php @@ -87,6 +87,16 @@ public function warm() $this->discoverStores()->each->warm(); } + /** + * AggregateStore's own warm() just fans out to child stores, which is exactly what + * warmValueIndexes()/warmOtherIndexes() do too, so it can still take the two-pass + * path. Subclasses that define their own warm() are reported as overriding it. + */ + public function overridesWarm(): bool + { + return (new \ReflectionMethod($this, 'warm'))->getDeclaringClass()->getName() !== self::class; + } + public function resetMemoizedState() { parent::resetMemoizedState(); diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 56afc8cd811..6d11c9de48c 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -414,6 +414,16 @@ public function warm() $this->warmOtherIndexes(); } + /** + * Whether this store provides its own warm() implementation. Such stores are warmed + * by calling warm() directly rather than through the two-pass split, so third-party + * overrides registered via Stache::registerStore() keep being honoured. + */ + public function overridesWarm(): bool + { + return (new \ReflectionMethod($this, 'warm'))->getDeclaringClass()->getName() !== self::class; + } + /** * Pass 1 of the 2-pass warm. Loads every file once and accumulates values for * all per-item value indexes in a single loop, then writes each index to the diff --git a/tests/Stache/StacheTest.php b/tests/Stache/StacheTest.php index dcd57dbc1d2..6063754d50f 100644 --- a/tests/Stache/StacheTest.php +++ b/tests/Stache/StacheTest.php @@ -248,4 +248,35 @@ public function warming_includes_on_the_fly_terms_in_term_value_indexes() $this->assertEquals(['en::alfa' => 'alfa', 'en::bravo' => 'bravo'], $store->index('slug')->items()->all()); $this->assertEquals(['en::alfa' => 'en', 'en::bravo' => 'en'], $store->index('site')->items()->all()); } + + #[Test] + public function warming_calls_warm_on_stores_that_override_it() + { + $store = new class($this->stache, \Mockery::mock(Filesystem::class)) extends CollectionsStore + { + public $warmed = false; + + public function warm() + { + $this->warmed = true; + } + + public function warmValueIndexes() + { + throw new \Exception('Should not be called on a store that overrides warm().'); + } + + public function warmOtherIndexes() + { + throw new \Exception('Should not be called on a store that overrides warm().'); + } + }; + + $this->stache->registerStore($store); + $this->stache->setLockFactory(new LockFactory(new NullLockStore)); + + $this->stache->warm(); + + $this->assertTrue($store->warmed); + } } From c985fee510f2967e4cccadf7d8b6647fee68ef65 Mon Sep 17 00:00:00 2001 From: edalzell Date: Mon, 14 Sep 2026 12:24:44 -0700 Subject: [PATCH 12/12] Reject only null term values when building associations --- src/Stache/Indexes/Terms/Associations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 8c6977f1a20..7dd927050d7 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -28,7 +28,7 @@ public function getItems() $sites = $entries->index('site'); return $entries->index($handle)->items() - ->filter() + ->reject(fn ($terms) => is_null($terms)) ->flatMap(fn ($terms, $key) => collect(Arr::wrap($terms))->map(fn ($term) => [ 'value' => $term, 'slug' => Str::slug($term),