diff --git a/README.md b/README.md index 847bdb29..ee7bbc11 100644 --- a/README.md +++ b/README.md @@ -225,4 +225,4 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl The `StaticCache::EVENT_BEFORE_PURGE` event fires immediately before each tag purge, including the collected end-of-request batch. Listeners can modify its `tags` or cancel the purge. -When a saved element purge proceeds, its non-null site URL is included in tag-based gateway API requests as the optional `fetchUrls` field. URLs are deduplicated, and the gateway asynchronously fetches them after a successful purge to repopulate the cache. Drafts, revisions, deletions, and canceled purges do not send URLs. +When a saved element purge proceeds, a Craft queue job sends its non-null site URL to the gateway as the optional `fetchUrls` field rather than making the gateway request inline. URLs are deduplicated, and the gateway asynchronously fetches them after a successful purge to repopulate the cache. Drafts, revisions, deletions, and canceled purges do not enqueue URLs. If the job cannot be queued, the purge tags fall back to the `Cache-Purge-Tag` response header. diff --git a/src/StaticCache.php b/src/StaticCache.php index fb648f04..c2d7a5d3 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -5,6 +5,7 @@ use Craft; use craft\base\ElementInterface; use craft\cloud\events\PurgeEvent; +use craft\cloud\queue\PurgeStaticCacheJob; use craft\events\ElementEvent; use craft\events\InvalidateElementCachesEvent; use craft\events\RegisterCacheOptionsEvent; @@ -400,27 +401,20 @@ private function sendPurgeTagsRequest( return; } - Module::info('Purging tags', [ - 'tags' => $tags, - 'fetchUrls' => $fetchUrls, - ]); - - $payload = [ + $job = new PurgeStaticCacheJob([ 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), - ]; - - if ($fetchUrls->isNotEmpty()) { - $payload['fetchUrls'] = $fetchUrls + 'fetchUrls' => $fetchUrls ->unique() ->values() - ->all(); - } + ->all(), + ]); try { - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - RequestOptions::JSON => $payload, - RequestOptions::TIMEOUT => 40, - ]); + if ($isWebResponse) { + Craft::$app->getQueue()->push($job); + } else { + $job->execute(Craft::$app->getQueue()); + } } catch (\Throwable $e) { if ($isWebResponse) { $this->setCacheTagHeader( diff --git a/src/queue/PurgeStaticCacheJob.php b/src/queue/PurgeStaticCacheJob.php new file mode 100644 index 00000000..45280abc --- /dev/null +++ b/src/queue/PurgeStaticCacheJob.php @@ -0,0 +1,48 @@ + $this->tags, + 'fetchUrls' => $this->fetchUrls, + ]); + + $payload = [ + 'tags' => $this->tags, + ]; + + if ($this->fetchUrls !== []) { + $payload['fetchUrls'] = $this->fetchUrls; + } + + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => $payload, + RequestOptions::TIMEOUT => 40, + ]); + } +} diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 319fa604..07b3709f 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -8,6 +8,7 @@ use craft\cloud\fs\AssetsFs; use craft\cloud\HeaderEnum; use craft\cloud\Module; +use craft\cloud\queue\PurgeStaticCacheJob; use craft\cloud\signing\RequestSigner; use craft\cloud\StaticCache; use craft\cloud\StaticCacheTag; @@ -15,6 +16,7 @@ use craft\events\ElementEvent; use craft\events\InvalidateElementCachesEvent; use craft\helpers\StringHelper; +use craft\queue\Queue; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use GuzzleHttp\Psr7\Response; @@ -23,6 +25,7 @@ use Psr\Http\Message\RequestInterface; use ReflectionMethod; use ReflectionProperty; +use Throwable; class StaticCacheTest extends Unit { @@ -34,15 +37,19 @@ class StaticCacheTest extends Unit private ?string $requestMethod = null; private ?string $environmentId = null; private ?Module $previousModule = null; + private mixed $previousQueue = null; + private ?CapturingQueue $queue = null; private ?RequestInterface $gatewayRequest = null; private array $gatewayRequestOptions = []; - private ?\Throwable $gatewayException = null; protected function _before(): void { parent::_before(); $this->previousModule = Module::getInstance(); + $this->previousQueue = Craft::$app->getComponents()['queue']; + $this->queue = new CapturingQueue(); + Craft::$app->set('queue', $this->queue); $module = new Module('cloud'); Module::setInstance($module); @@ -56,10 +63,6 @@ protected function _before(): void $module->set('requestSigner', new class(function(RequestInterface $request, array $options) { $this->gatewayRequest = $request; $this->gatewayRequestOptions = $options; - - if ($this->gatewayException) { - throw $this->gatewayException; - } }) extends RequestSigner { public function __construct(private readonly \Closure $capture) { @@ -84,6 +87,7 @@ protected function _after(): void { Craft::$app->getRequest()->setIsCpRequest(null); Craft::$app->getResponse()->clear(); + Craft::$app->set('queue', $this->previousQueue); Module::getInstance()->getConfig()->environmentId = $this->environmentId; Module::setInstance($this->previousModule); @@ -387,12 +391,12 @@ public function testDraftCacheInvalidationDoesNotPurge(): void $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); } - public function testFailedFetchRequestFallsBackToPurgeHeader(): void + public function testFailedQueueDispatchFallsBackToPurgeHeader(): void { $staticCache = new StaticCache(); $element = new FetchableElement(['uri' => 'news']); $element->fetchUrl = 'https://example.com/news'; - $this->gatewayException = new \RuntimeException(); + $this->queue->exception = new \RuntimeException(); $this->saveElement($staticCache, $element); @@ -405,9 +409,10 @@ public function testFailedFetchRequestFallsBackToPurgeHeader(): void '123-environment-id:uri:/news', Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), ); + $this->assertNull($this->gatewayRequest); } - public function testGatewayPurgeAllowsBoundedRetries(): void + public function testQueuedGatewayPurgeAllowsBoundedRetries(): void { $staticCache = new StaticCache(); $element = new FetchableElement(['uri' => 'news']); @@ -415,10 +420,29 @@ public function testGatewayPurgeAllowsBoundedRetries(): void $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); + $this->queue->job->execute($this->queue); $this->assertSame(40, $this->gatewayRequestOptions[RequestOptions::TIMEOUT]); } + public function testConsolePurgeRunsGatewayRequestImmediately(): void + { + $response = Craft::$app->getResponse(); + Craft::$app->set('response', new \yii\console\Response()); + + try { + (new StaticCache())->purgeTags('immediate'); + } finally { + Craft::$app->set('response', $response); + } + + $this->assertNull($this->queue->job); + $this->assertSame( + ['tags' => ['immediate']], + json_decode((string) $this->gatewayRequest?->getBody(), true, flags: JSON_THROW_ON_ERROR), + ); + } + public function testBeforePurgeEventCanCancelExistingHeaderTags(): void { $staticCache = new StaticCache(); @@ -491,6 +515,7 @@ public function testOverlongElementUriPurgeUsesOverflowTag(): void $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); + $this->queue->job->execute($this->queue); $payload = json_decode( (string) $this->gatewayRequest?->getBody(), @@ -513,6 +538,7 @@ public function testCancelledElementPurgeDoesNotSendFetch(): void $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); + $this->assertNull($this->queue->job); $this->assertNull($this->gatewayRequest); } @@ -523,12 +549,18 @@ public function testDeletedElementPurgeDoesNotCollectFetch(): void $element->fetchUrl = 'https://example.com/news'; $this->deleteElement($staticCache, $element); + $this->sendPendingPurgeTags($staticCache); $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isNotEmpty()); $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + $this->assertNull($this->queue->job); + $this->assertSame( + '123-environment-id:uri:/news', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); } - public function testSavedElementPurgeRequestIncludesFetchUrls(): void + public function testSavedElementPurgeQueuesGatewayJob(): void { $staticCache = new StaticCache(); $englishElement = new FetchableElement(['uri' => 'news']); @@ -544,6 +576,16 @@ public function testSavedElementPurgeRequestIncludesFetchUrls(): void $this->saveElement($staticCache, $urlLessElement); $this->sendPendingPurgeTags($staticCache); + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['123-environment-id:uri:/news'], $this->queue->job->tags); + $this->assertSame([ + 'https://example.com/news', + 'https://example.com/fr/nouvelles', + ], $this->queue->job->fetchUrls); + $this->assertNull($this->gatewayRequest); + + $this->queue->job->execute($this->queue); + $payload = json_decode( (string) $this->gatewayRequest?->getBody(), true, @@ -720,3 +762,24 @@ public function getUrl(): ?string return $this->fetchUrl; } } + +class CapturingQueue extends Queue +{ + public mixed $job = null; + public ?Throwable $exception = null; + + public function init(): void + { + } + + public function push($job): ?string + { + if ($this->exception) { + throw $this->exception; + } + + $this->job = $job; + + return '1'; + } +}