Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Stache/Indexes/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions src/Stache/Indexes/Terms/Associations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion src/Stache/Stache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/Stache/Stores/AggregateStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
51 changes: 47 additions & 4 deletions src/Stache/Stores/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading