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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 10 additions & 16 deletions src/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle a null queue push as dispatch failure

When the configured queue declines the push by returning null rather than throwing—for example, when a before-push handler cancels it—this path treats dispatch as successful even though the purge header was already removed. The saved-element purge is then neither queued nor sent through the response header, leaving the cached page stale; check the returned job ID and apply the same header fallback when it is null.

Useful? React with 👍 / 👎.

} else {
$job->execute(Craft::$app->getQueue());
}
} catch (\Throwable $e) {
if ($isWebResponse) {
$this->setCacheTagHeader(
Expand Down
48 changes: 48 additions & 0 deletions src/queue/PurgeStaticCacheJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace craft\cloud\queue;

use craft\cloud\Helper;
use craft\cloud\Module;
use craft\i18n\Translation;
use craft\queue\BaseJob;
use GuzzleHttp\RequestOptions;

class PurgeStaticCacheJob extends BaseJob
{
/**
* @var string[]
*/
public array $tags = [];

/**
* @var string[]
*/
public array $fetchUrls = [];

protected function defaultDescription(): ?string
{
return Translation::prep('app', 'Purging static cache');
}

public function execute($queue): void
{
Module::info('Purging tags', [
'tags' => $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,
]);
}
}
81 changes: 72 additions & 9 deletions tests/unit/StaticCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
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;
use craft\elements\Entry;
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;
Expand All @@ -23,6 +25,7 @@
use Psr\Http\Message\RequestInterface;
use ReflectionMethod;
use ReflectionProperty;
use Throwable;

class StaticCacheTest extends Unit
{
Expand All @@ -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);

Expand All @@ -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)
{
Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -405,20 +409,40 @@ 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']);
$element->fetchUrl = 'https://example.com/news';

$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();
Expand Down Expand Up @@ -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(),
Expand All @@ -513,6 +538,7 @@ public function testCancelledElementPurgeDoesNotSendFetch(): void
$this->saveElement($staticCache, $element);
$this->sendPendingPurgeTags($staticCache);

$this->assertNull($this->queue->job);
$this->assertNull($this->gatewayRequest);
}

Expand All @@ -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']);
Expand All @@ -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,
Expand Down Expand Up @@ -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';
}
}
Loading