diff --git a/README.md b/README.md index 847bdb29..4c91a710 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. +Web requests dispatch tag purges to the gateway with a Craft queue job. When a saved element purge proceeds, its non-null site URL is included 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. Non-web purges execute the same job immediately. diff --git a/src/HeaderEnum.php b/src/HeaderEnum.php index 6aef5734..89f4520a 100644 --- a/src/HeaderEnum.php +++ b/src/HeaderEnum.php @@ -5,7 +5,6 @@ enum HeaderEnum: string { case CACHE_TAG = 'Cache-Tag'; - case CACHE_PURGE_TAG = 'Cache-Purge-Tag'; case CACHE_PURGE_PREFIX = 'Cache-Purge-Prefix'; case CACHE_CONTROL = 'Cache-Control'; case CDN_CACHE_CONTROL = 'CDN-Cache-Control'; diff --git a/src/Helper.php b/src/Helper.php index 2f9ab5c8..d3c91d35 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -36,36 +36,24 @@ public static function makeGatewayApiRequest(iterable $headers): ResponseInterfa ->values() ->all(); - $tags = []; $prefixes = []; foreach ($headers as $name => $value) { - if (HeaderEnum::CACHE_PURGE_TAG->matches((string) $name)) { - $tags = $normalizeHeaderValue($value); - - if (!empty($tags)) { - break; - } - - continue; - } - if (HeaderEnum::CACHE_PURGE_PREFIX->matches((string) $name)) { $prefixes = $normalizeHeaderValue($value); + break; } } - if (empty($tags) && empty($prefixes)) { - throw new Exception('Gateway API requests require a supported purge header.'); + if (empty($prefixes)) { + throw new Exception('Gateway API requests require a Cache-Purge-Prefix header.'); } return self::createGatewayApiClient()->request( 'POST', 'cache/purge', [ - RequestOptions::JSON => !empty($tags) - ? ['tags' => $tags] - : ['prefixes' => $prefixes], + RequestOptions::JSON => ['prefixes' => $prefixes], ], ); } diff --git a/src/StaticCache.php b/src/StaticCache.php index fb648f04..ab98a017 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; @@ -24,7 +25,7 @@ use yii\caching\TagDependency; /** - * Static Cache tags can appear in the `Cache-Tag` and `Cache-Purge-Tag` headers. + * Static Cache tags can appear in the `Cache-Tag` header. * The values are comma-separated and can be in several formats: * * - Added by the gateway: @@ -348,14 +349,6 @@ private function sendPurgeTagsRequest( Collection $tags, ?Collection $fetchUrls = null, ): void { - $response = Craft::$app->getResponse(); - $isWebResponse = $response instanceof \craft\web\Response; - - // Add any existing tags from the response headers - if ($isWebResponse) { - $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); - } - $tags = $this->normalizeCacheTags(...$tags); if ($tags->isEmpty()) { @@ -367,10 +360,6 @@ private function sendPurgeTagsRequest( ]); $this->trigger(self::EVENT_BEFORE_PURGE, $event); - if ($isWebResponse) { - $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); - } - $overflowTag = $this->overflowTag(); $tags = $event->isValid ? $this->normalizeCacheTags(...$event->tags) @@ -383,53 +372,18 @@ private function sendPurgeTagsRequest( } $fetchUrls ??= Collection::make(); - $sendApiRequest = !$isWebResponse || $fetchUrls->isNotEmpty(); - - if (!$sendApiRequest) { - $tags = $this->truncateCacheTagsForHeader($tags); - - Module::info('Purging tags', [ - 'tags' => $tags, - ]); - - $this->setCacheTagHeader( - HeaderEnum::CACHE_PURGE_TAG->value, - $tags, - ); - - 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(); - } - - try { - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - RequestOptions::JSON => $payload, - RequestOptions::TIMEOUT => 40, - ]); - } catch (\Throwable $e) { - if ($isWebResponse) { - $this->setCacheTagHeader( - HeaderEnum::CACHE_PURGE_TAG->value, - $this->truncateCacheTagsForHeader($tags), - ); - } + ->all(), + ]); - throw $e; + if (Craft::$app->getResponse() instanceof \craft\web\Response) { + Craft::$app->getQueue()->push($job); + } else { + $job->execute(Craft::$app->getQueue()); } } diff --git a/src/queue/PurgeStaticCacheJob.php b/src/queue/PurgeStaticCacheJob.php new file mode 100644 index 00000000..ca565d36 --- /dev/null +++ b/src/queue/PurgeStaticCacheJob.php @@ -0,0 +1,47 @@ + $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, + ]); + } +} diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 319fa604..2d413ff7 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,14 +16,15 @@ 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; -use GuzzleHttp\RequestOptions; use Illuminate\Support\Collection; use Psr\Http\Message\RequestInterface; use ReflectionMethod; use ReflectionProperty; +use Throwable; class StaticCacheTest extends Unit { @@ -34,15 +36,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 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); @@ -53,13 +58,8 @@ protected function _before(): void $this->environmentId = $module->getConfig()->environmentId; $module->getConfig()->environmentId = '123-environment-id'; $module->getConfig()->signingKey = 'test-signing-key'; - $module->set('requestSigner', new class(function(RequestInterface $request, array $options) { + $module->set('requestSigner', new class(function(RequestInterface $request) { $this->gatewayRequest = $request; - $this->gatewayRequestOptions = $options; - - if ($this->gatewayException) { - throw $this->gatewayException; - } }) extends RequestSigner { public function __construct(private readonly \Closure $capture) { @@ -69,7 +69,7 @@ public function __construct(private readonly \Closure $capture) public function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack { return new HandlerStack(function(RequestInterface $request, array $options) { - ($this->capture)($request, $options); + ($this->capture)($request); return Create::promiseFor(new Response(204)); }); @@ -84,6 +84,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); @@ -252,19 +253,6 @@ public function testCacheTagOverflowTruncatesTagsThatExceedTheMaximumCount(): vo $this->assertNotContains('tag-1000', $tags); } - public function testPurgeTagsKeepExistingHeaderTags(): void - { - $staticCache = new StaticCache(); - Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'first,second'); - - $staticCache->purgeTags(); - - $this->assertSame( - 'first,second', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); - } - public function testPurgeAllUsesOriginAndCdnTags(): void { $staticCache = new StaticCache(); @@ -288,16 +276,12 @@ public function testAssetCdnPurgeUsesEnvironmentFirstTag(): void $method->setAccessible(true); $this->assertTrue($method->invoke($fs, 'image.jpg')); - $this->assertFalse( - Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), - ); - $this->sendPendingPurgeTags($staticCache); - $this->assertSame( + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame([ '123-environment-id:cdn:123-environment-id/assets/image.jpg', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); + ], $this->queue->job->tags); } public function testRasterizedAssetCdnPurgeUsesObjectTag(): void @@ -348,10 +332,8 @@ public function testOverlongAssetCdnPurgeUsesOverflowTag(): void $this->assertTrue($method->invoke($fs, str_repeat('x', 1024))); $this->sendPendingPurgeTags($staticCache); - $this->assertSame( - '123-environment-id:overflow', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['123-environment-id:overflow'], $this->queue->job->tags); } public function testBeforePurgeEventCanCancelPurge(): void @@ -364,9 +346,7 @@ public function testBeforePurgeEventCanCancelPurge(): void $staticCache->purgeTags('first'); - $this->assertFalse( - Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), - ); + $this->assertNull($this->queue->job); } public function testDraftCacheInvalidationDoesNotPurge(): void @@ -387,69 +367,34 @@ public function testDraftCacheInvalidationDoesNotPurge(): void $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); } - public function testFailedFetchRequestFallsBackToPurgeHeader(): void + public function testFailedQueueDispatchThrows(): 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); - try { - $this->sendPendingPurgeTags($staticCache); - } catch (\RuntimeException) { - } - - $this->assertSame( - '123-environment-id:uri:/news', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); - } - - public function testGatewayPurgeAllowsBoundedRetries(): void - { - $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => 'news']); - $element->fetchUrl = 'https://example.com/news'; - - $this->saveElement($staticCache, $element); + $this->expectException(\RuntimeException::class); $this->sendPendingPurgeTags($staticCache); - - $this->assertSame(40, $this->gatewayRequestOptions[RequestOptions::TIMEOUT]); - } - - public function testBeforePurgeEventCanCancelExistingHeaderTags(): void - { - $staticCache = new StaticCache(); - Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'first'); - $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { - $event->isValid = false; - }); - - $staticCache->purgeTags(); - - $this->assertFalse( - Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), - ); } - public function testBeforePurgeEventExceptionPreservesExistingHeaderTags(): void + public function testConsolePurgeRunsGatewayRequestImmediately(): void { - $staticCache = new StaticCache(); - Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'first'); - $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function() { - throw new \RuntimeException(); - }); + $response = Craft::$app->getResponse(); + Craft::$app->set('response', new \yii\console\Response()); try { - $staticCache->purgeTags('second'); - } catch (\RuntimeException) { + (new StaticCache())->purgeTags('immediate'); + } finally { + Craft::$app->set('response', $response); } + $this->assertNull($this->queue->job); $this->assertSame( - 'first', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ['tags' => ['immediate']], + json_decode((string) $this->gatewayRequest?->getBody(), true, flags: JSON_THROW_ON_ERROR), ); } @@ -462,10 +407,8 @@ public function testBeforePurgeEventCanReplaceTags(): void $staticCache->purgeTags('first'); - $this->assertSame( - 'second', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['second'], $this->queue->job->tags); } public function testHomepagePurgeUsesUriTag(): void @@ -491,6 +434,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 +457,7 @@ public function testCancelledElementPurgeDoesNotSendFetch(): void $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); + $this->assertNull($this->queue->job); $this->assertNull($this->gatewayRequest); } @@ -523,12 +468,16 @@ 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->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['123-environment-id:uri:/news'], $this->queue->job->tags); + $this->assertSame([], $this->queue->job->fetchUrls); } - public function testSavedElementPurgeRequestIncludesFetchUrls(): void + public function testSavedElementPurgeQueuesGatewayJob(): void { $staticCache = new StaticCache(); $englishElement = new FetchableElement(['uri' => 'news']); @@ -544,6 +493,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, @@ -555,9 +514,6 @@ public function testSavedElementPurgeRequestIncludesFetchUrls(): void 'https://example.com/news', 'https://example.com/fr/nouvelles', ], $payload['fetchUrls']); - $this->assertFalse( - Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), - ); } public function testBeforePurgeEventFiresOnceForCollectedBatch(): void @@ -589,10 +545,8 @@ public function testPurgeTagsDoesNotSendCollectedBatch(): void $staticCache->addPurgeTags(['collected']); $staticCache->purgeTags('immediate'); - $this->assertSame( - 'immediate', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), - ); + $this->assertInstanceOf(PurgeStaticCacheJob::class, $this->queue->job); + $this->assertSame(['immediate'], $this->queue->job->tags); $this->assertSame( ['collected'], $this->collectionProperty($staticCache, 'tagsToPurge') @@ -614,21 +568,6 @@ public function testExistingCacheTagHeaderIsSplit(): void ); } - public function testPurgeTagHeaderUsesOverflowFallbackWhenItIsTooLong(): void - { - $staticCache = new StaticCache(); - $tags = Collection::range(1, 1000) - ->map(fn(int $index) => "tag-$index-" . str_repeat('x', 24)) - ->all(); - - $staticCache->purgeTags(...$tags); - - $cachePurgeTagHeader = Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value); - - $this->assertStringStartsWith('123-environment-id:overflow,', $cachePurgeTagHeader); - $this->assertLessThanOrEqual(16 * 1024, StringHelper::byteLength($cachePurgeTagHeader)); - } - private function isCacheable(StaticCache $staticCache): bool { $method = new ReflectionMethod($staticCache, 'isCacheable'); @@ -720,3 +659,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'; + } +}