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 @@ -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.
28 changes: 11 additions & 17 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 @@ -333,27 +334,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(
Expand Down
54 changes: 54 additions & 0 deletions src/queue/PurgeStaticCacheJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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 = [];

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

/**
* @inheritdoc
*/
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 => 3,
]);
}
}
58 changes: 50 additions & 8 deletions tests/unit/StaticCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
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;
use craft\elements\Entry;
use craft\events\ElementEvent;
use craft\helpers\StringHelper;
use craft\queue\Queue;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Collection;
use Psr\Http\Message\RequestInterface;
use ReflectionMethod;
use ReflectionProperty;
use Throwable;

class StaticCacheTest extends Unit
{
Expand All @@ -31,14 +34,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);

Expand All @@ -51,10 +58,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)
{
Expand All @@ -79,6 +82,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 @@ -232,12 +236,12 @@ public function testBeforePurgeEventCanCancelPurge(): void
);
}

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 @@ -250,6 +254,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
Expand Down Expand Up @@ -319,12 +324,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');
Expand All @@ -344,6 +355,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,
Expand Down Expand Up @@ -461,3 +482,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