diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index 24e4b5206ba..be16073cad9 100644 --- a/src/Console/Commands/StaticWarm.php +++ b/src/Console/Commands/StaticWarm.php @@ -51,6 +51,8 @@ class StaticWarm extends Command private $uris; + private $paginated = []; + public function handle() { if (! config('statamic.static_caching.strategy')) { @@ -114,20 +116,22 @@ private function warm(): void $promise = $pool->promise(); $promise->wait(); + + $this->warmPaginatedPages(); } } - private function warmPaginatedPages(string $url, int $currentPage, int $totalPages, string $pageName): void + /** + * Paginated pages are only known about once their first page has responded. They're + * warmed together afterwards, so the pool of other URLs is never left waiting on them. + */ + private function warmPaginatedPages(): void { - $urls = collect(range($currentPage, $totalPages))->map(function ($page) use ($url, $pageName) { - $url = "{$url}?{$pageName}={$page}"; - - if (config('statamic.static_caching.background_recache', false)) { - $url = RecacheToken::addToUrl($url); - } + $urls = $this->paginatedUrls(); - return $url; - }); + if ($urls->isEmpty()) { + return; + } $headers = $this->parseHeaders($this->option('header')); @@ -175,12 +179,32 @@ public function outputSuccessLine(Response $response, $index): void $this->components->twoColumnDetail($this->getRelativeUri($this->uris()->get($index)), '✓ Cached'); if ($response->hasHeader('X-Statamic-Pagination')) { - [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); - - $this->warmPaginatedPages($this->uris()->get($index), $currentPage, $totalPages, $pageName); + $this->paginated[] = [$this->uris()->get($index), ...$this->paginationHeader($response)]; } } + private function paginatedUrls(): Collection + { + $cacher = app(StaticCacher::class); + + return collect($this->paginated)->flatMap(function ($paginated) use ($cacher) { + [$url, $currentPage, $totalPages, $pageName] = $paginated; + + $cachedAs = $cacher->getUrl(HttpRequest::create($url)); + + return collect(range($currentPage, $totalPages)) + ->map(fn ($page) => $url.(str_contains($url, '?') ? '&' : '?')."{$pageName}={$page}") + // When query strings are ignored, a page is cached under the same URL + // as the one that has just been warmed, so there's nothing left to warm. + ->reject(fn ($pageUrl) => $cacher->getUrl(HttpRequest::create($pageUrl)) === $cachedAs) + ->map(function ($pageUrl) { + return config('statamic.static_caching.background_recache', false) + ? RecacheToken::addToUrl($pageUrl) + : $pageUrl; + }); + })->values(); + } + public function outputFailureLine($exception, $index): void { $this->outputFailureLineFor($this->uris()->get($index), $exception); diff --git a/src/Console/Commands/StaticWarmJob.php b/src/Console/Commands/StaticWarmJob.php index c240d478afd..86fbdb0f626 100644 --- a/src/Console/Commands/StaticWarmJob.php +++ b/src/Console/Commands/StaticWarmJob.php @@ -8,9 +8,11 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Http\Request as HttpRequest; use Illuminate\Queue\InteractsWithQueue; use Psr\Http\Message\ResponseInterface; use Statamic\Console\Commands\Concerns\NormalizesPaginationHeader; +use Statamic\StaticCaching\Cacher; class StaticWarmJob implements ShouldBeUnique, ShouldQueue { @@ -31,6 +33,9 @@ public function handle() if ($this->shouldWarmPaginatedPages($response)) { [$currentPage, $totalPages, $pageName] = $this->paginationHeader($response); + $cacher = app(Cacher::class); + $cachedAs = $cacher->getUrl(HttpRequest::create((string) $this->request->getUri())); + collect(range($currentPage, $totalPages)) ->map(function (int $page) use ($pageName): string { $url = $this->request->getUri(); @@ -41,6 +46,9 @@ public function handle() "{$pageName}={$page}", ]); }) + // When query strings are ignored, a page is cached under the same URL + // as the one that has just been warmed, so there's nothing left to warm. + ->reject(fn (string $uri) => $cacher->getUrl(HttpRequest::create($uri)) === $cachedAs) ->each(fn (string $uri) => static::dispatch( new Request('GET', $uri, $this->request->getHeaders()), $this->clientConfig diff --git a/tests/Console/Commands/StaticWarmJobTest.php b/tests/Console/Commands/StaticWarmJobTest.php index 854da245376..4842fe11923 100644 --- a/tests/Console/Commands/StaticWarmJobTest.php +++ b/tests/Console/Commands/StaticWarmJobTest.php @@ -50,6 +50,31 @@ public function it_dispatches_paginated_pages_with_the_same_job_class() Queue::assertPushed(CustomPaginatedStaticWarmJob::class, 2); } + #[Test] + public function it_doesnt_dispatch_paginated_pages_that_are_cached_under_the_same_url() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'statamic.static_caching.ignore_query_strings' => true, + ]); + + Queue::fake(); + + $mock = new MockHandler([ + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 3, + 'name' => 'page', + ]), + ]); + + $job = new StaticWarmJob(new Request('GET', 'http://localhost/blog'), ['handler' => HandlerStack::create($mock)]); + + $job->handle(); + + Queue::assertNothingPushed(); + } + #[Test] public function it_sends_a_get_request_and_dispatches_static_warm_job_for_page_with_pagination() { diff --git a/tests/Console/Commands/StaticWarmTest.php b/tests/Console/Commands/StaticWarmTest.php index 87d12937023..59d573ba5b8 100644 --- a/tests/Console/Commands/StaticWarmTest.php +++ b/tests/Console/Commands/StaticWarmTest.php @@ -12,6 +12,7 @@ use Mockery; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Console\Commands\StaticWarm; use Statamic\Console\Commands\StaticWarmJob; use Statamic\Console\Commands\StaticWarmUncachedJob; use Statamic\Facades\Collection; @@ -370,6 +371,127 @@ public function it_sets_custom_headers_on_paginated_requests() } } + #[Test] + public function it_warms_paginated_pages_after_the_other_urls() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'statamic.static_caching.strategies.half.warm_concurrency' => 1, + ]); + + $history = []; + $this->mockClient([ + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 3, + 'name' => 'page', + ]), + new Response(200), + new Response(200), + new Response(200), + new Response(200), + ], $history); + + $this->artisan('statamic:static:warm')->assertExitCode(0); + + $visited = collect($history)->map(function ($transaction) { + $uri = $transaction['request']->getUri(); + + return $uri->getPath().($uri->getQuery() ? '?'.$uri->getQuery() : ''); + })->all(); + + $this->assertEquals(['/about', '/contact', '/about?page=1', '/about?page=2', '/about?page=3'], $visited); + } + + #[Test] + public function it_adds_the_page_to_a_url_that_already_has_a_query_string() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'statamic.static_caching.strategies.half.warm_concurrency' => 1, + ]); + + StaticWarm::hook('additional', fn () => collect(['/zebra?filter=stripes'])); + + $history = []; + $this->mockClient([ + new Response(200), + new Response(200), + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 2, + 'name' => 'page', + ]), + new Response(200), + new Response(200), + ], $history); + + $this->artisan('statamic:static:warm')->assertExitCode(0); + + $queries = collect($history)->map(fn ($transaction) => $transaction['request']->getUri()->getQuery())->filter()->values()->all(); + + $this->assertEquals(['filter=stripes', 'filter=stripes&page=1', 'filter=stripes&page=2'], $queries); + } + + #[Test] + public function it_doesnt_warm_paginated_pages_that_are_cached_under_the_same_url() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'statamic.static_caching.ignore_query_strings' => true, + ]); + + $history = []; + $this->mockClient([ + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 3, + 'name' => 'page', + ]), + new Response(200), + new Response(200), + new Response(200), + new Response(200), + ], $history); + + $this->artisan('statamic:static:warm')->assertExitCode(0); + + $this->assertCount(2, $history); + } + + #[Test] + public function it_only_warms_paginated_pages_whose_query_string_is_part_of_the_cached_url() + { + config([ + 'statamic.static_caching.strategy' => 'half', + 'statamic.static_caching.allowed_query_strings' => ['page'], + ]); + + $history = []; + $this->mockClient([ + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 2, + 'name' => 'page', + ]), + (new Response(200))->withHeader('X-Statamic-Pagination', [ + 'current' => 1, + 'total' => 2, + 'name' => 'p', + ]), + new Response(200), + new Response(200), + new Response(200), + new Response(200), + ], $history); + + $this->artisan('statamic:static:warm')->assertExitCode(0); + + $queries = collect($history)->map(fn ($transaction) => $transaction['request']->getUri()->getQuery())->filter()->values()->all(); + + $this->assertEquals(['page=1', 'page=2'], $queries); + } + private function mockClient(array $responses, array &$history) { $handlerStack = HandlerStack::create(new MockHandler($responses));