diff --git a/src/Stache/Indexes/Index.php b/src/Stache/Indexes/Index.php index 61182a2e005..00126931048 100644 --- a/src/Stache/Indexes/Index.php +++ b/src/Stache/Indexes/Index.php @@ -59,6 +59,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..e4ccf2e5ee9 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; @@ -10,24 +11,58 @@ class Associations extends Index { public function getItems() { - return Taxonomy::findByHandle($handle = $this->store->childKey()) + $handle = $this->store->childKey(); + + return Taxonomy::findByHandle($handle) ->collections() ->flatMap(function ($collection) use ($handle) { - 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(); + $entriesStore = Stache::store('entries')->store($collection->handle()); + // Hoist outside the loop to avoid repeated method calls per entry. + $collectionHandle = $collection->handle(); + $results = []; + + // Two earlier approaches both caused excess memory usage: + // 1. queryEntries()->get()->flatMap() — loaded all matching entries at once. + // 2. queryEntries()->lazy()->flatMap() — chunked loading, but each Entry + // object was still kept alive for the duration of its flatMap closure, + // so entries accumulated within each chunk. + // With 3000+ entries containing large Bard content, both caused ~2.5 GB peak RSS. + // Iterating paths directly lets us unset each Entry immediately after + // extracting the scalar values we need, so PHP can reclaim memory + // per-entry rather than holding everything until flatMap returns. + foreach ($entriesStore->paths()->keys() as $key) { + $item = $entriesStore->getItem($key); + + if (! $item) { + continue; + } + + $value = $item->value($handle); + + if (empty($value)) { + // Release the entry object before moving to the next key. + unset($item); + + continue; + } + + $entryId = $item->id(); + $site = $item->locale(); + // Release the entry object now that we have all the scalars we need. + unset($item); + + foreach ((array) $value as $termValue) { + $results[] = [ + 'value' => $termValue, + 'slug' => Str::slug($termValue), + 'entry' => $entryId, + 'collection' => $collectionHandle, + 'site' => $site, + ]; + } + } + + return $results; })->all(); } diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index c220b679e1b..5dfc0bfa97a 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -406,12 +406,30 @@ public function clear() public function warm() { - $this->shouldCacheFileItems = true; + $indexes = $this->resolveIndexes(); - $this->resolveIndexes()->each->update(); + // Partition: indexes implementing getItemValue() can be built in a single pass. + // Others (e.g. Terms/Associations) query their own data sources independently. + [$valueIndexes, $otherIndexes] = $indexes->partition( + fn ($index) => method_exists($index, 'getItemValue') + ); - $this->shouldCacheFileItems = false; - $this->fileItems = null; + // Single pass: hold one item in memory at a time while feeding all value indexes. + $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); + } + } + + $valueIndexes->each(function ($index, $name) use ($accumulated) { + $index->setItems($accumulated[$name])->cache(); + }); + + $otherIndexes->each->update(); } public function keys() diff --git a/tests/Stache/Indexes/Terms/AssociationsTest.php b/tests/Stache/Indexes/Terms/AssociationsTest.php new file mode 100644 index 00000000000..fcb2ee76a20 --- /dev/null +++ b/tests/Stache/Indexes/Terms/AssociationsTest.php @@ -0,0 +1,88 @@ +save(); + + Collection::make('blog') + ->sites(['en']) + ->taxonomies(['tags']) + ->save(); + + Entry::make()->id('entry-1')->locale('en')->collection('blog')->slug('one')->data(['tags' => ['alfa', 'bravo']])->save(); + Entry::make()->id('entry-2')->locale('en')->collection('blog')->slug('two')->data(['tags' => ['alfa']])->save(); + Entry::make()->id('entry-3')->locale('en')->collection('blog')->slug('three')->data(['title' => 'No tags'])->save(); + + $associations = Stache::store('terms')->store('tags')->index('associations'); + $associations->update(); + + $items = collect($associations->items()->all()); + + $this->assertCount(3, $items); + + $alfaItems = $items->where('slug', 'alfa')->values(); + $this->assertCount(2, $alfaItems); + $this->assertTrue($alfaItems->pluck('entry')->contains('entry-1')); + $this->assertTrue($alfaItems->pluck('entry')->contains('entry-2')); + + $bravoItems = $items->where('slug', 'bravo')->values(); + $this->assertCount(1, $bravoItems); + $this->assertEquals('entry-1', $bravoItems->first()['entry']); + + $items->each(function ($item) { + $this->assertEquals('blog', $item['collection']); + $this->assertEquals('en', $item['site']); + }); + } + + #[Test] + public function it_builds_associations_across_multiple_collections(): void + { + Taxonomy::make('tags')->save(); + + Collection::make('blog')->sites(['en'])->taxonomies(['tags'])->save(); + Collection::make('news')->sites(['en'])->taxonomies(['tags'])->save(); + + Entry::make()->id('blog-1')->locale('en')->collection('blog')->slug('blog-one')->data(['tags' => ['alfa']])->save(); + Entry::make()->id('news-1')->locale('en')->collection('news')->slug('news-one')->data(['tags' => ['alfa']])->save(); + + $associations = Stache::store('terms')->store('tags')->index('associations'); + $associations->update(); + + $alfaItems = collect($associations->items()->all())->where('slug', 'alfa')->values(); + + $this->assertCount(2, $alfaItems); + $this->assertTrue($alfaItems->pluck('collection')->contains('blog')); + $this->assertTrue($alfaItems->pluck('collection')->contains('news')); + } + + #[Test] + public function it_returns_empty_when_no_entries_have_the_taxonomy(): void + { + Taxonomy::make('tags')->save(); + + Collection::make('blog')->sites(['en'])->taxonomies(['tags'])->save(); + + Entry::make()->id('entry-1')->locale('en')->collection('blog')->slug('one')->data(['title' => 'No tags'])->save(); + + $associations = Stache::store('terms')->store('tags')->index('associations'); + $associations->update(); + + $this->assertEmpty($associations->items()->all()); + } +} diff --git a/tests/Stache/Stores/StoreWarmTest.php b/tests/Stache/Stores/StoreWarmTest.php new file mode 100644 index 00000000000..67aaa44cd91 --- /dev/null +++ b/tests/Stache/Stores/StoreWarmTest.php @@ -0,0 +1,118 @@ +withItems([ + 'key-a' => 'Alpha', + 'key-b' => 'Beta', + ]); + + Stache::registerStore($store); + + $store->warm(); + + $this->assertEquals( + ['key-a' => 'Alpha', 'key-b' => 'Beta'], + $store->index('name')->items()->all() + ); + } + + #[Test] + public function it_handles_an_empty_store(): void + { + $store = (new WarmableTestStore)->withItems([]); + + Stache::registerStore($store); + + $store->warm(); + + $this->assertEquals([], $store->index('name')->items()->all()); + } + + #[Test] + public function it_updates_non_value_indexes_via_their_own_update_method(): void + { + $store = (new WarmableTestStore)->withItems([]); + + Stache::registerStore($store); + + $store->warm(); + + $this->assertEquals( + ['static-key' => 'static-value'], + $store->index('static')->items()->all() + ); + } +} + +class WarmableTestStore extends Store +{ + protected array $items = []; + + protected $defaultIndexes = []; + + protected $storeIndexes = [ + 'name' => WarmableNameIndex::class, + 'static' => WarmableStaticIndex::class, + ]; + + public function key(): string + { + return 'warmable'; + } + + public function withItems(array $items): static + { + $this->items = $items; + + return $this; + } + + public function paths() + { + return collect(array_fill_keys(array_keys($this->items), '/fake')); + } + + public function getItem($key): string + { + return $this->items[$key] ?? ''; + } + + public function getItemKey($item): string + { + return $item; + } + + public function getItemValues($keys, $valueIndex, $keyIndex): array + { + return []; + } +} + +class WarmableNameIndex extends Value +{ + public function getItemValue($item): string + { + return $item; + } +} + +class WarmableStaticIndex extends Index +{ + public function getItems(): array + { + return ['static-key' => 'static-value']; + } +}