Skip to content
23 changes: 23 additions & 0 deletions config/stache.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,27 @@
'timeout' => 30,
],

/*
|--------------------------------------------------------------------------
| Warming Optimization
|--------------------------------------------------------------------------
|
| These options control performance optimizations during Stache warming.
|
*/

'warming' => [
// Enable parallel store processing for faster warming on multi-core systems
'parallel_processing' => env('STATAMIC_STACHE_PARALLEL_WARMING', false),

// Maximum number of parallel processes (0 = auto-detect CPU cores)
'max_processes' => env('STATAMIC_STACHE_MAX_PROCESSES', 0),

// Minimum number of stores required to enable parallel processing
'min_stores_for_parallel' => env('STATAMIC_STACHE_MIN_STORES_PARALLEL', 3),

// Concurrency driver: 'process', 'fork', or 'sync'
'concurrency_driver' => env('STATAMIC_STACHE_CONCURRENCY_DRIVER', 'process'),
],

];
5 changes: 4 additions & 1 deletion src/Dictionaries/BasicDictionary.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ protected function matchesSearchQuery(string $query, Item $item): bool
{
$query = strtolower($query);

// Pre-compute searchable lookup for O(1) access instead of O(n) in_array()
$searchableLookup = empty($this->searchable) ? null : array_flip($this->searchable);

foreach ($item->extra() as $key => $value) {
if (! empty($this->searchable) && ! in_array($key, $this->searchable)) {
if ($searchableLookup !== null && ! isset($searchableLookup[$key])) {
continue;
}

Expand Down
1 change: 1 addition & 0 deletions src/Stache/Indexes/Terms/Value.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Value extends Index
public function getItems()
{
$associatedItems = $this->store->index('associations')->items()
->filter()
->mapWithKeys(function ($association) {
$term = Term::make($value = $association['slug'])
->taxonomy($this->store->childKey())
Expand Down
16 changes: 10 additions & 6 deletions src/Stache/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,16 +157,20 @@ protected function filterWhereBasic($values, $where)

protected function filterWhereIn($values, $where)
{
return $values->filter(function ($value) use ($where) {
return in_array($value, $where['values']);
});
$lookup = array_flip($where['values']);

return $values->filter(
fn ($value) => isset($lookup[$value])
);
}

protected function filterWhereNotIn($values, $where)
{
return $values->filter(function ($value) use ($where) {
return ! in_array($value, $where['values']);
});
$lookup = array_flip($where['values']);

return $values->filter(
fn ($value) => ! isset($lookup[$value])
);
}

protected function filterWhereNull($values, $where)
Expand Down
13 changes: 13 additions & 0 deletions src/Stache/Repositories/EntryRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,16 @@ public function findByUri(string $uri, ?string $site = null): ?Entry

public function whereInId($ids): EntryCollection
{
if (empty($ids)) {
return EntryCollection::make();
}

$entries = $this->query()->whereIn('id', $ids)->get();

if ($entries->isEmpty()) {
return EntryCollection::make();
}

$entriesById = $entries->keyBy->id();

$ordered = collect($ids)
Expand Down Expand Up @@ -175,6 +184,10 @@ public function substitute($item)

public function applySubstitutions($items)
{
if (empty($this->substitutionsById)) {
return $items;
}

return $items->map(function ($item) {
return $this->substitutionsById[$item->id()] ?? $item;
});
Expand Down
4 changes: 4 additions & 0 deletions src/Stache/Repositories/SubmissionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public function whereForm(string $handle): Collection

public function whereInForm(array $handles): Collection
{
if (empty($handles)) {
return collect();
}

return $this->query()->whereIn('form', $handles)->get();
}

Expand Down
8 changes: 8 additions & 0 deletions src/Stache/Repositories/TermRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public function whereTaxonomy(string $handle): TermCollection

public function whereInTaxonomy(array $handles): TermCollection
{
if (empty($handles)) {
return TermCollection::make();
}

collect($handles)
->reject(fn ($taxonomy) => Taxonomy::find($taxonomy))
->each(fn ($taxonomy) => throw new TaxonomyNotFoundException($taxonomy));
Expand Down Expand Up @@ -199,6 +203,10 @@ public function substitute($item)

public function applySubstitutions($items)
{
if (empty($this->substitutionsById)) {
return $items;
}

return $items->map(function ($item) {
return $this->substitutionsById[$item->id()] ?? $item;
});
Expand Down
85 changes: 84 additions & 1 deletion src/Stache/Stache.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Concurrency;
use Statamic\Events\StacheCleared;
use Statamic\Events\StacheWarmed;
use Statamic\Extensions\FileStore;
Expand Down Expand Up @@ -118,7 +119,13 @@ public function warm()

$this->startTimer();

$this->stores()->except($this->exclude)->each->warm();
$stores = $this->stores()->except($this->exclude);

if ($this->shouldUseParallelWarming($stores)) {
$this->warmInParallel($stores);
} else {
$stores->each->warm();
}

$this->stopTimer();

Expand Down Expand Up @@ -232,4 +239,80 @@ public function isWatcherEnabled(): bool
? app()->isLocal()
: (bool) $config;
}

protected function shouldUseParallelWarming($stores): bool
{
$config = config('statamic.stache.warming', []);

if (! ($config['parallel_processing'] ?? false)) {
return false;
}

if ($stores->count() < ($config['min_stores_for_parallel'] ?? 3)) {
return false;
}

if ($this->getCpuCoreCount() < 2) {
return false;
}

// Disable parallel processing if using Redis cache (serialization issues)
$cacheDriver = config('statamic.stache.cache_store', config('cache.default'));
if ($cacheDriver === 'redis') {
\Log::info('Parallel warming disabled due to Redis cache driver');

return false;
}

return true;
}

protected function warmInParallel($stores)
{
try {
$config = config('statamic.stache.warming', []);
$maxProcesses = $config['max_processes'] ?? 0;

if ($maxProcesses <= 0) {
$maxProcesses = $this->getCpuCoreCount();
}

$maxProcesses = min($maxProcesses, $stores->count());

$chunkSize = (int) ceil($stores->count() / $maxProcesses);
$chunks = $stores->chunk($chunkSize);

$closures = $chunks->map(function ($chunk) {
return function () use ($chunk) {
return $chunk->each->warm()->keys()->all();
};
})->all();

$driver = $config['concurrency_driver'] ?? 'process';

if (empty($closures)) {
\Log::info('Closures are empty, skipping parallel warming');
}

Concurrency::driver($driver)->run($closures);
} catch (\Exception $e) {
\Log::warning('Parallel warming failed, falling back to sequential: '.$e->getMessage());
$stores->each->warm();
}
}

protected function getCpuCoreCount(): int
{
if (! function_exists('shell_exec')) {
return 1;
}

$command = match (PHP_OS_FAMILY) {
'Windows' => 'echo %NUMBER_OF_PROCESSORS%',
'Darwin' => 'sysctl -n hw.ncpu 2>/dev/null || echo 1',
default => 'nproc 2>/dev/null || echo 1',
};

return max(1, (int) shell_exec($command));
}
}
1 change: 0 additions & 1 deletion src/Stache/Stores/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Statamic\Stache\Stores;

use Facades\Statamic\Stache\Traverser;
use Illuminate\Support\Facades\Cache;
use Statamic\Facades\File;
use Statamic\Facades\Path;
use Statamic\Facades\Stache;
Expand Down
26 changes: 11 additions & 15 deletions src/Stache/Traverser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@

namespace Statamic\Stache;

use Illuminate\Filesystem\Filesystem;
use Statamic\Facades\Path;
use Symfony\Component\Finder\Finder;

class Traverser
{
protected $filesystem;
protected $filter;

public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}

public function traverse($store)
{
if (! $dir = $store->directory()) {
Expand All @@ -23,20 +17,22 @@ public function traverse($store)

$dir = rtrim($dir, '/');

if (! $this->filesystem->exists($dir)) {
if (! file_exists($dir)) {
return collect();
}

$files = collect($this->filesystem->allFiles($dir));
$files = Finder::create()->files()->ignoreDotFiles(true)->in($dir)->sortByName();

$paths = [];
foreach ($files as $file) {
if ($this->filter && ! call_user_func($this->filter, $file)) {
continue;
}

if ($this->filter) {
$files = $files->filter($this->filter);
$paths[Path::tidy($file->getPathname())] = $file->getMTime();
}

return $files
->mapWithKeys(function ($file) {
return [Path::tidy($file->getPathname()) => $file->getMTime()];
})->sort();
return collect($paths)->sort();
}

public function filter($filter)
Expand Down
2 changes: 1 addition & 1 deletion tests/Stache/TraverserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function setUp(): void
$this->tempDir = __DIR__.'/tmp';
mkdir($this->tempDir);

$this->traverser = new Traverser(new Filesystem);
$this->traverser = new Traverser();
}

public function tearDown(): void
Expand Down