diff --git a/src/Stache/Indexes/Index.php b/src/Stache/Indexes/Index.php index 61182a2e005..dcbe5101c93 100644 --- a/src/Stache/Indexes/Index.php +++ b/src/Stache/Indexes/Index.php @@ -59,6 +59,17 @@ 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; + + 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..8c23142828d 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -2,17 +2,62 @@ namespace Statamic\Stache\Indexes\Terms; +use Statamic\Facades\Stache; use Statamic\Facades\Taxonomy; use Statamic\Stache\Indexes\Index; use Statamic\Support\Str; 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 + * 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. + */ 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() diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index 406980b04c7..59eb0ccdc17 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -132,7 +132,12 @@ public function warm() if ($this->shouldUseParallelWarming($stores)) { $this->warmInParallel($stores); } else { - $stores->each->warm(); + // 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(); } $this->stopTimer(); diff --git a/src/Stache/Stores/AggregateStore.php b/src/Stache/Stores/AggregateStore.php index 334c0b63835..5d537e919ec 100644 --- a/src/Stache/Stores/AggregateStore.php +++ b/src/Stache/Stores/AggregateStore.php @@ -87,6 +87,18 @@ public function warm() $this->discoverStores()->each->warm(); } + /** @see Store::warmValueIndexes() */ + public function warmValueIndexes() + { + $this->discoverStores()->each->warmValueIndexes(); + } + + /** @see Store::warmOtherIndexes() */ + 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 c220b679e1b..cc28a1abdf1 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -406,12 +406,55 @@ public function clear() public function warm() { - $this->shouldCacheFileItems = true; + $this->warmValueIndexes(); + $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( + 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) { + $item = $this->getItem($key); + + foreach ($valueIndexes as $name => $index) { + $accumulated[$name][$key] = $index->getItemValue($item); + } + } - $this->resolveIndexes()->each->update(); + $valueIndexes->each(function ($index, $name) use ($accumulated) { + $index->setItems($accumulated[$name])->cache(); + }); + } - $this->shouldCacheFileItems = false; - $this->fileItems = null; + /** + * 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() + ->filter(fn ($index) => ! method_exists($index, 'getItemValue')) + ->each->update(); } public function keys()