diff --git a/README.md b/README.md index 94347e2..770276c 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,4 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl ### Static cache -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. +The `StaticCache::EVENT_BEFORE_PURGE` event fires immediately before each tag purge or invalidation, including the collected end-of-request batch. Listeners can modify its `tags` or cancel the request. diff --git a/src/StaticCache.php b/src/StaticCache.php index fb648f0..b4e6d9a 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -61,7 +61,6 @@ class StaticCache extends \yii\base\Component private ?int $cacheDuration = null; private Collection $tags; private Collection $tagsToPurge; - private Collection $fetchUrls; private bool $collectingCacheInfo = false; /** @var bool[] */ private array $graphqlCachingStack = []; @@ -70,7 +69,6 @@ public function init(): void { $this->tags = Collection::make(); $this->tagsToPurge = Collection::make(); - $this->fetchUrls = Collection::make(); } public function registerEventHandlers(): void @@ -132,17 +130,13 @@ public function registerEventHandlers(): void Craft::$app->onAfterRequest(function() { if ($this->tagsToPurge->isNotEmpty()) { try { - $this->sendPurgeTagsRequest( - $this->tagsToPurge, - $this->fetchUrls, - ); + $this->sendInvalidateTagsRequest($this->tagsToPurge); } catch (\Throwable $e) { Module::error( - 'Failed to purge tags after request', + 'Failed to invalidate tags after request', [ 'exception' => $e, 'tags' => $this->tagsToPurge->all(), - 'fetchUrls' => $this->fetchUrls->all(), ], ); } @@ -246,15 +240,7 @@ private function handleRegisterCacheOptions(RegisterCacheOptionsEvent $event): v private function handleSaveElement(ElementEvent $event): void { - if (!$this->purgeElementUri($event->element)) { - return; - } - - $url = $event->element->getUrl(); - - if ($url !== null) { - $this->fetchUrls->push($url); - } + $this->purgeElementUri($event->element); } private function handleDeleteElement(ElementEvent $event): void @@ -336,6 +322,11 @@ public function purgeTags(string|StaticCacheTag ...$tags): void $this->sendPurgeTagsRequest(Collection::make($tags)); } + public function invalidateTags(string|StaticCacheTag ...$tags): void + { + $this->sendInvalidateTagsRequest(Collection::make($tags)); + } + /** * @param array $tags */ @@ -344,48 +335,17 @@ public function addPurgeTags(array $tags): void $this->tagsToPurge->push(...$this->normalizeCacheTags(...$tags)); } - private function sendPurgeTagsRequest( - Collection $tags, - ?Collection $fetchUrls = null, - ): void { + private function sendPurgeTagsRequest(Collection $tags): 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); + $tags = $this->preparePurgeTags($tags, $isWebResponse); if ($tags->isEmpty()) { return; } - $event = new PurgeEvent([ - 'tags' => $tags->values()->all(), - ]); - $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) - ->map(fn(StaticCacheTag $tag) => StringHelper::byteLength($tag->getValue()) > self::MAX_TAG_VALUE_LENGTH ? $overflowTag : $tag) - ->unique(fn(StaticCacheTag $tag) => $tag->getValue()) - : Collection::make(); - - if ($tags->isEmpty()) { - return; - } - - $fetchUrls ??= Collection::make(); - $sendApiRequest = !$isWebResponse || $fetchUrls->isNotEmpty(); - - if (!$sendApiRequest) { $tags = $this->truncateCacheTagsForHeader($tags); Module::info('Purging tags', [ @@ -402,23 +362,35 @@ private function sendPurgeTagsRequest( Module::info('Purging tags', [ 'tags' => $tags, - 'fetchUrls' => $fetchUrls, ]); - $payload = [ - 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), - ]; + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => [ + 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), + ], + RequestOptions::TIMEOUT => 40, + ]); + } + + private function sendInvalidateTagsRequest(Collection $tags): void + { + $response = Craft::$app->getResponse(); + $isWebResponse = $response instanceof \craft\web\Response; + $tags = $this->preparePurgeTags($tags, $isWebResponse); - if ($fetchUrls->isNotEmpty()) { - $payload['fetchUrls'] = $fetchUrls - ->unique() - ->values() - ->all(); + if ($tags->isEmpty()) { + return; } + Module::info('Invalidating tags', [ + 'tags' => $tags, + ]); + try { - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - RequestOptions::JSON => $payload, + Helper::createGatewayApiClient()->request('POST', 'cache/invalidate', [ + RequestOptions::JSON => [ + 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), + ], RequestOptions::TIMEOUT => 40, ]); } catch (\Throwable $e) { @@ -433,6 +405,36 @@ private function sendPurgeTagsRequest( } } + private function preparePurgeTags(Collection $tags, bool $includeResponseHeader): Collection + { + if ($includeResponseHeader) { + $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); + } + + $tags = $this->normalizeCacheTags(...$tags); + + if ($tags->isEmpty()) { + return $tags; + } + + $event = new PurgeEvent([ + 'tags' => $tags->values()->all(), + ]); + $this->trigger(self::EVENT_BEFORE_PURGE, $event); + + if ($includeResponseHeader) { + Craft::$app->getResponse()->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); + } + + $overflowTag = $this->overflowTag(); + + return $event->isValid + ? $this->normalizeCacheTags(...$event->tags) + ->map(fn(StaticCacheTag $tag) => StringHelper::byteLength($tag->getValue()) > self::MAX_TAG_VALUE_LENGTH ? $overflowTag : $tag) + ->unique(fn(StaticCacheTag $tag) => $tag->getValue()) + : Collection::make(); + } + public function purgeUrlPrefixes(string ...$urlPrefixes): void { $urlPrefixes = Collection::make($urlPrefixes)->filter()->unique(); @@ -452,6 +454,25 @@ public function purgeUrlPrefixes(string ...$urlPrefixes): void ]); } + public function invalidateUrlPrefixes(string ...$urlPrefixes): void + { + $urlPrefixes = Collection::make($urlPrefixes)->filter()->unique(); + + if ($urlPrefixes->isEmpty()) { + return; + } + + Module::info('Invalidating URL prefixes', [ + 'urlPrefixes' => $urlPrefixes->all(), + ]); + + Helper::createGatewayApiClient()->request('POST', 'cache/invalidate', [ + RequestOptions::JSON => [ + 'prefixes' => $urlPrefixes->values()->all(), + ], + ]); + } + private function isCacheable(): bool { $request = Craft::$app->getRequest(); diff --git a/src/cli/controllers/StaticCacheController.php b/src/cli/controllers/StaticCacheController.php index b4e89b2..74b76e5 100644 --- a/src/cli/controllers/StaticCacheController.php +++ b/src/cli/controllers/StaticCacheController.php @@ -9,6 +9,64 @@ class StaticCacheController extends Controller { + public function actionInvalidateAll(): int + { + $this->do('Invalidating static cache', function() { + $module = Module::getInstance(); + $environmentId = $this->environmentId(); + $module->getStaticCache()->invalidateTags( + "$environmentId:uri", + "$environmentId:cdn", + "$environmentId:rasterize", + ); + }); + + return ExitCode::OK; + } + + public function actionInvalidateCdn(): int + { + $this->do('Invalidating CDN static cache', function() { + $module = Module::getInstance(); + $environmentId = $this->environmentId(); + $module->getStaticCache()->invalidateTags( + "$environmentId:cdn", + "$environmentId:rasterize", + ); + }); + + return ExitCode::OK; + } + + public function actionInvalidateOrigin(): int + { + $this->do('Invalidating origin static cache', function() { + $module = Module::getInstance(); + $environmentId = $this->environmentId(); + $module->getStaticCache()->invalidateTags("$environmentId:uri"); + }); + + return ExitCode::OK; + } + + public function actionInvalidatePrefixes(string ...$prefixes): int + { + $this->do('Invalidating prefixes', function() use ($prefixes) { + Module::getInstance()->getStaticCache()->invalidateUrlPrefixes(...$prefixes); + }); + + return ExitCode::OK; + } + + public function actionInvalidateTags(string ...$tags): int + { + $this->do('Invalidating tags', function() use ($tags) { + Module::getInstance()->getStaticCache()->invalidateTags(...$tags); + }); + + return ExitCode::OK; + } + public function actionPurgeAll(): int { $this->do('Purging static cache', function() { diff --git a/tests/unit/StaticCacheControllerTest.php b/tests/unit/StaticCacheControllerTest.php index 92361be..6708eea 100644 --- a/tests/unit/StaticCacheControllerTest.php +++ b/tests/unit/StaticCacheControllerTest.php @@ -40,12 +40,35 @@ public function testPurgeActions(): void $controller->actionPurgeAll(); $controller->actionPurgeOrigin(); $controller->actionPurgeCdn(); + $controller->actionPurgePrefixes('/first', '/second'); + $controller->actionPurgeTags('first', 'second'); $this->assertSame([ ['123-environment-id:uri', '123-environment-id:cdn', '123-environment-id:rasterize'], ['123-environment-id:uri'], ['123-environment-id:cdn', '123-environment-id:rasterize'], + ['first', 'second'], ], $this->staticCache->purges); + $this->assertSame([['/first', '/second']], $this->staticCache->purgedPrefixes); + } + + public function testInvalidateActions(): void + { + $controller = new StaticCacheController('static-cache', Craft::$app); + + $controller->actionInvalidateAll(); + $controller->actionInvalidateOrigin(); + $controller->actionInvalidateCdn(); + $controller->actionInvalidatePrefixes('/first', '/second'); + $controller->actionInvalidateTags('first', 'second'); + + $this->assertSame([ + ['123-environment-id:uri', '123-environment-id:cdn', '123-environment-id:rasterize'], + ['123-environment-id:uri'], + ['123-environment-id:cdn', '123-environment-id:rasterize'], + ['first', 'second'], + ], $this->staticCache->invalidations); + $this->assertSame([['/first', '/second']], $this->staticCache->invalidatedPrefixes); } public function testPurgeFailurePropagates(): void @@ -78,6 +101,9 @@ public function testPurgeRequiresNonEmptyEnvironmentId(): void class TestStaticCache extends StaticCache { public array $purges = []; + public array $purgedPrefixes = []; + public array $invalidations = []; + public array $invalidatedPrefixes = []; public bool $fail = false; public function purgeTags(string|StaticCacheTag ...$tags): void @@ -88,4 +114,19 @@ public function purgeTags(string|StaticCacheTag ...$tags): void $this->purges[] = $tags; } + + public function purgeUrlPrefixes(string ...$urlPrefixes): void + { + $this->purgedPrefixes[] = $urlPrefixes; + } + + public function invalidateTags(string|StaticCacheTag ...$tags): void + { + $this->invalidations[] = $tags; + } + + public function invalidateUrlPrefixes(string ...$urlPrefixes): void + { + $this->invalidatedPrefixes[] = $urlPrefixes; + } } diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 319fa60..3c0a3ab 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -279,7 +279,7 @@ public function testPurgeAllUsesOriginAndCdnTags(): void ); } - public function testAssetCdnPurgeUsesEnvironmentFirstTag(): void + public function testAssetCdnInvalidationUsesEnvironmentFirstTag(): void { $staticCache = new StaticCache(); Module::getInstance()->set('staticCache', $staticCache); @@ -295,9 +295,10 @@ public function testAssetCdnPurgeUsesEnvironmentFirstTag(): void $this->sendPendingPurgeTags($staticCache); $this->assertSame( - '123-environment-id:cdn:123-environment-id/assets/image.jpg', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ['tags' => ['123-environment-id:cdn:123-environment-id/assets/image.jpg']], + json_decode((string) $this->gatewayRequest?->getBody(), true, flags: JSON_THROW_ON_ERROR), ); + $this->assertSame('/api/123-environment-id/cache/invalidate', $this->gatewayRequest?->getUri()->getPath()); } public function testRasterizedAssetCdnPurgeUsesObjectTag(): void @@ -337,7 +338,7 @@ public function testOverlongRasterizedAssetCdnPurgeUsesEnvironmentTag(): void ); } - public function testOverlongAssetCdnPurgeUsesOverflowTag(): void + public function testOverlongAssetCdnInvalidationUsesOverflowTag(): void { $staticCache = new StaticCache(); Module::getInstance()->set('staticCache', $staticCache); @@ -349,8 +350,8 @@ public function testOverlongAssetCdnPurgeUsesOverflowTag(): void $this->sendPendingPurgeTags($staticCache); $this->assertSame( - '123-environment-id:overflow', - Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ['tags' => ['123-environment-id:overflow']], + json_decode((string) $this->gatewayRequest?->getBody(), true, flags: JSON_THROW_ON_ERROR), ); } @@ -387,11 +388,10 @@ public function testDraftCacheInvalidationDoesNotPurge(): void $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); } - public function testFailedFetchRequestFallsBackToPurgeHeader(): void + public function testFailedInvalidationRequestFallsBackToPurgeHeader(): void { $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => 'news']); - $element->fetchUrl = 'https://example.com/news'; + $element = new Entry(['uri' => 'news']); $this->gatewayException = new \RuntimeException(); $this->saveElement($staticCache, $element); @@ -407,16 +407,16 @@ public function testFailedFetchRequestFallsBackToPurgeHeader(): void ); } - public function testGatewayPurgeAllowsBoundedRetries(): void + public function testGatewayInvalidationAllowsBoundedRetries(): void { $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => 'news']); - $element->fetchUrl = 'https://example.com/news'; + $element = new Entry(['uri' => 'news']); $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); $this->assertSame(40, $this->gatewayRequestOptions[RequestOptions::TIMEOUT]); + $this->assertSame('/api/123-environment-id/cache/invalidate', $this->gatewayRequest?->getUri()->getPath()); } public function testBeforePurgeEventCanCancelExistingHeaderTags(): void @@ -486,8 +486,7 @@ public function testHomepagePurgeUsesUriTag(): void public function testOverlongElementUriPurgeUsesOverflowTag(): void { $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => str_repeat('x', 1024)]); - $element->fetchUrl = 'https://example.com/overlong'; + $element = new Entry(['uri' => str_repeat('x', 1024)]); $this->saveElement($staticCache, $element); $this->sendPendingPurgeTags($staticCache); @@ -501,11 +500,10 @@ public function testOverlongElementUriPurgeUsesOverflowTag(): void $this->assertSame(['123-environment-id:overflow'], $payload['tags']); } - public function testCancelledElementPurgeDoesNotSendFetch(): void + public function testCancelledElementPurgeDoesNotSendInvalidation(): void { $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => 'news']); - $element->fetchUrl = 'https://example.com/news'; + $element = new Entry(['uri' => 'news']); $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { $event->isValid = false; }); @@ -516,33 +514,33 @@ public function testCancelledElementPurgeDoesNotSendFetch(): void $this->assertNull($this->gatewayRequest); } - public function testDeletedElementPurgeDoesNotCollectFetch(): void + public function testSavedElementUsesTagOnlyInvalidationRequest(): void { $staticCache = new StaticCache(); - $element = new FetchableElement(['uri' => 'news']); - $element->fetchUrl = 'https://example.com/news'; + $element = new Entry(['uri' => 'news']); + + $this->saveElement($staticCache, $element); + $this->saveElement($staticCache, $element); + $this->sendPendingPurgeTags($staticCache); - $this->deleteElement($staticCache, $element); + $payload = json_decode( + (string) $this->gatewayRequest?->getBody(), + true, + flags: JSON_THROW_ON_ERROR, + ); - $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isNotEmpty()); - $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + $this->assertSame(['tags' => ['123-environment-id:uri:/news']], $payload); + $this->assertSame('/api/123-environment-id/cache/invalidate', $this->gatewayRequest?->getUri()->getPath()); + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); } - public function testSavedElementPurgeRequestIncludesFetchUrls(): void + public function testInvalidatePrefixesUsesPrefixesOnly(): void { $staticCache = new StaticCache(); - $englishElement = new FetchableElement(['uri' => 'news']); - $englishElement->fetchUrl = 'https://example.com/news'; - $frenchElement = clone $englishElement; - $frenchElement->fetchUrl = 'https://example.com/fr/nouvelles'; - $urlLessElement = clone $englishElement; - $urlLessElement->fetchUrl = null; - - $this->saveElement($staticCache, $englishElement); - $this->saveElement($staticCache, $englishElement); - $this->saveElement($staticCache, $frenchElement); - $this->saveElement($staticCache, $urlLessElement); - $this->sendPendingPurgeTags($staticCache); + + $staticCache->invalidateUrlPrefixes('', '/news', '/news'); $payload = json_decode( (string) $this->gatewayRequest?->getBody(), @@ -550,14 +548,8 @@ public function testSavedElementPurgeRequestIncludesFetchUrls(): void flags: JSON_THROW_ON_ERROR, ); - $this->assertSame(['123-environment-id:uri:/news'], $payload['tags']); - $this->assertSame([ - 'https://example.com/news', - 'https://example.com/fr/nouvelles', - ], $payload['fetchUrls']); - $this->assertFalse( - Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), - ); + $this->assertSame(['prefixes' => ['/news']], $payload); + $this->assertSame('/api/123-environment-id/cache/invalidate', $this->gatewayRequest?->getUri()->getPath()); } public function testBeforePurgeEventFiresOnceForCollectedBatch(): void @@ -677,21 +669,13 @@ private function saveElement(StaticCache $staticCache, Entry $element): void $method->invoke($staticCache, new ElementEvent(['element' => $element])); } - private function deleteElement(StaticCache $staticCache, Entry $element): void - { - $method = new ReflectionMethod($staticCache, 'handleDeleteElement'); - $method->setAccessible(true); - $method->invoke($staticCache, new ElementEvent(['element' => $element])); - } - private function sendPendingPurgeTags(StaticCache $staticCache): void { - $method = new ReflectionMethod($staticCache, 'sendPurgeTagsRequest'); + $method = new ReflectionMethod($staticCache, 'sendInvalidateTagsRequest'); $method->setAccessible(true); $method->invoke( $staticCache, $this->collectionProperty($staticCache, 'tagsToPurge'), - $this->collectionProperty($staticCache, 'fetchUrls'), ); } @@ -710,13 +694,3 @@ private function setTags(StaticCache $staticCache, Collection $tags): void $property->setValue($staticCache, $tags); } } - -class FetchableElement extends Entry -{ - public ?string $fetchUrl = null; - - public function getUrl(): ?string - { - return $this->fetchUrl; - } -}