Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
145 changes: 83 additions & 62 deletions src/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -70,7 +69,6 @@ public function init(): void
{
$this->tags = Collection::make();
$this->tagsToPurge = Collection::make();
$this->fetchUrls = Collection::make();
}

public function registerEventHandlers(): void
Expand Down Expand Up @@ -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(),
],
);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<array-key, string|StaticCacheTag> $tags
*/
Expand All @@ -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', [
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve hard-purge headers during invalidation

When a web request already has a Cache-Purge-Tag header (for example, after calling purgeTags()) and also queues an automatic element or asset invalidation, this call tells preparePurgeTags() to merge that header into the invalidation batch and remove it from the response. On a successful cache/invalidate request, the explicit hard purge is therefore silently downgraded to an invalidation and never reaches the gateway as a purge header. The invalidation path should process only its supplied tags while preserving any existing hard-purge header.

Useful? React with 👍 / 👎.


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) {
Expand All @@ -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();
Expand All @@ -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(),
],
]);
Comment on lines +469 to +473
}

private function isCacheable(): bool
{
$request = Craft::$app->getRequest();
Expand Down
58 changes: 58 additions & 0 deletions src/cli/controllers/StaticCacheController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,64 @@

class StaticCacheController extends Controller
{
public function actionInvalidateAll(): int
{
$this->do('Invalidating static cache', function() {
$module = Module::getInstance();
$environmentId = $this->environmentId();
Comment on lines +15 to +16
$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() {
Expand Down
41 changes: 41 additions & 0 deletions tests/unit/StaticCacheControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
Loading
Loading