diff --git a/README.md b/README.md index 34dce56..693ee0a 100644 --- a/README.md +++ b/README.md @@ -245,4 +245,4 @@ return [ ]; ``` -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 enqueue 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 8104e46..87b9ca5 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; @@ -341,27 +342,20 @@ private function sendTagPurgeRequest(Collection $tags, ?Collection $fetchUrls = 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 - ->unique() + 'fetchUrls' => $fetchUrls + ?->unique() ->values() - ->all(); - } + ->all() ?? [], + ]); try { - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - RequestOptions::JSON => $payload, - RequestOptions::TIMEOUT => 3, - ]); + 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 0000000..81b74f3 --- /dev/null +++ b/src/queue/PurgeStaticCacheJob.php @@ -0,0 +1,54 @@ + $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 => 10, + ]); + } +} diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index ce87696..849deac 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -7,6 +7,7 @@ use craft\cloud\events\PurgeEvent; 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; @@ -14,6 +15,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; @@ -21,6 +23,7 @@ use Psr\Http\Message\RequestInterface; use ReflectionMethod; use ReflectionProperty; +use Throwable; class StaticCacheTest extends Unit { @@ -32,14 +35,18 @@ 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 ?\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); @@ -52,10 +59,6 @@ protected function _before(): void $module->getConfig()->signingKey = 'test-signing-key'; $module->set('requestSigner', new class(function(RequestInterface $request) { $this->gatewayRequest = $request; - - if ($this->gatewayException) { - throw $this->gatewayException; - } }) extends RequestSigner { public function __construct(private readonly \Closure $capture) { @@ -80,6 +83,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); @@ -251,12 +255,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); @@ -269,6 +273,7 @@ public function testFailedFetchRequestFallsBackToPurgeHeader(): void '123-environment-id:/news', Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), ); + $this->assertNull($this->gatewayRequest); } public function testBeforePurgeEventCanCancelExistingHeaderTags(): void @@ -338,12 +343,18 @@ public function testDeletedElementPurgeDoesNotQueueFetch(): 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:/news', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); } - public function testSavedElementPurgeRequestIncludesFetchUrls(): void + public function testSavedElementPurgeQueuesGatewayJob(): void { $staticCache = new StaticCache(); Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'cancelled'); @@ -363,6 +374,16 @@ public function testSavedElementPurgeRequestIncludesFetchUrls(): void $this->saveElement($staticCache, $urlLessElement); $this->sendPendingPurgeTags($staticCache); + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['123-environment-id:/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, @@ -480,3 +501,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'; + } +}