From ccfa846a1f783701f15eb07abbe694bcdf0c6411 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 15:00:22 +0000 Subject: [PATCH 1/2] Warm paginated pages after the other URLs, and skip ones cached under the same URL When a warmed page reported pagination, static:warm started a second pool of requests for its other pages and waited for it from inside the first pool's callback. Nothing else was sent while it waited, so the first pool kept emptying out and refilling. The paginated pages are now collected and warmed in one pool once the first has finished. When query strings are ignored, every page of a paginated URL is cached under that URL, so requesting them only returned what had just been cached. Those requests are now skipped, in the command and in the queued job. --- src/Console/Commands/StaticWarm.php | 48 ++++++++--- src/Console/Commands/StaticWarmJob.php | 8 ++ tests/Console/Commands/StaticWarmJobTest.php | 25 ++++++ tests/Console/Commands/StaticWarmTest.php | 91 ++++++++++++++++++++ 4 files changed, 160 insertions(+), 12 deletions(-) diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index 24e4b5206ba..3a330edda95 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}?{$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..ef2da9dc9c4 100644 --- a/tests/Console/Commands/StaticWarmTest.php +++ b/tests/Console/Commands/StaticWarmTest.php @@ -370,6 +370,97 @@ 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_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)); From 76c144e8d19be73e1710287043ea82d165eab679 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 15:55:08 +0000 Subject: [PATCH 2/2] Add the page to a URL that already has a query string with an ampersand --- src/Console/Commands/StaticWarm.php | 2 +- tests/Console/Commands/StaticWarmTest.php | 31 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/StaticWarm.php b/src/Console/Commands/StaticWarm.php index 3a330edda95..be16073cad9 100644 --- a/src/Console/Commands/StaticWarm.php +++ b/src/Console/Commands/StaticWarm.php @@ -193,7 +193,7 @@ private function paginatedUrls(): Collection $cachedAs = $cacher->getUrl(HttpRequest::create($url)); return collect(range($currentPage, $totalPages)) - ->map(fn ($page) => "{$url}?{$pageName}={$page}") + ->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) diff --git a/tests/Console/Commands/StaticWarmTest.php b/tests/Console/Commands/StaticWarmTest.php index ef2da9dc9c4..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; @@ -402,6 +403,36 @@ public function it_warms_paginated_pages_after_the_other_urls() $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() {