diff --git a/config/stache.php b/config/stache.php index 2d2ec830d36..fa739311ece 100644 --- a/config/stache.php +++ b/config/stache.php @@ -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'), + ], + ]; diff --git a/src/Dictionaries/BasicDictionary.php b/src/Dictionaries/BasicDictionary.php index 52e2f7363c3..77cb0d672a5 100644 --- a/src/Dictionaries/BasicDictionary.php +++ b/src/Dictionaries/BasicDictionary.php @@ -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; } diff --git a/src/Stache/Indexes/Terms/Value.php b/src/Stache/Indexes/Terms/Value.php index a5e13a48575..817510f505e 100644 --- a/src/Stache/Indexes/Terms/Value.php +++ b/src/Stache/Indexes/Terms/Value.php @@ -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()) diff --git a/src/Stache/Query/Builder.php b/src/Stache/Query/Builder.php index e4e1eabe06e..f1e89825c36 100644 --- a/src/Stache/Query/Builder.php +++ b/src/Stache/Query/Builder.php @@ -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) diff --git a/src/Stache/Repositories/EntryRepository.php b/src/Stache/Repositories/EntryRepository.php index fe82fde6c07..9c0fe65e812 100644 --- a/src/Stache/Repositories/EntryRepository.php +++ b/src/Stache/Repositories/EntryRepository.php @@ -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) @@ -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; }); diff --git a/src/Stache/Repositories/SubmissionRepository.php b/src/Stache/Repositories/SubmissionRepository.php index f10396aaef6..15e9f430af7 100644 --- a/src/Stache/Repositories/SubmissionRepository.php +++ b/src/Stache/Repositories/SubmissionRepository.php @@ -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(); } diff --git a/src/Stache/Repositories/TermRepository.php b/src/Stache/Repositories/TermRepository.php index 2e4fb08ed36..ff0cc578e4a 100644 --- a/src/Stache/Repositories/TermRepository.php +++ b/src/Stache/Repositories/TermRepository.php @@ -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)); @@ -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; }); diff --git a/src/Stache/Stache.php b/src/Stache/Stache.php index e130ae8d7a6..3a5c2fc03e3 100644 --- a/src/Stache/Stache.php +++ b/src/Stache/Stache.php @@ -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; @@ -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(); @@ -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)); + } } diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 6b14619e7aa..a6a144fcaa5 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -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; diff --git a/src/Stache/Traverser.php b/src/Stache/Traverser.php index 84723f0534f..3e3e5bbeebe 100644 --- a/src/Stache/Traverser.php +++ b/src/Stache/Traverser.php @@ -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()) { @@ -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) diff --git a/tests/Stache/TraverserTest.php b/tests/Stache/TraverserTest.php index db57da10fb0..26d241fb766 100644 --- a/tests/Stache/TraverserTest.php +++ b/tests/Stache/TraverserTest.php @@ -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