Skip to content
Closed
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
7 changes: 7 additions & 0 deletions src/Stache/Indexes/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
67 changes: 51 additions & 16 deletions src/Stache/Indexes/Terms/Associations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}

Expand Down
26 changes: 22 additions & 4 deletions src/Stache/Stores/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
88 changes: 88 additions & 0 deletions tests/Stache/Indexes/Terms/AssociationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace Tests\Stache\Indexes\Terms;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Statamic\Facades\Stache;
use Statamic\Facades\Taxonomy;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class AssociationsTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

#[Test]
public function it_builds_associations_from_entries_in_linked_collections(): 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(['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());
}
}
118 changes: 118 additions & 0 deletions tests/Stache/Stores/StoreWarmTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

namespace Tests\Stache\Stores;

use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Stache;
use Statamic\Stache\Indexes\Index;
use Statamic\Stache\Indexes\Value;
use Statamic\Stache\Stores\Store;
use Tests\TestCase;

class StoreWarmTest extends TestCase
{
#[Test]
public function it_builds_value_indexes_in_a_single_pass(): void
{
$store = (new WarmableTestStore)->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'];
}
}
Loading