Skip to content
Merged
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
20 changes: 19 additions & 1 deletion src/API/Cachers/DefaultCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Statamic\API\AbstractCacher;
use Statamic\Events\Event;
use WeakMap;

class DefaultCacher extends AbstractCacher
{
private ?WeakMap $generations = null;

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -45,6 +49,8 @@ public function put(Request $request, JsonResponse $response)
*/
public function handleInvalidationEvent(Event $event)
{
Cache::forget($this->getGenerationKey());

$this->getTrackedResponses()->each(function ($key) {
Cache::forget($key);
});
Expand Down Expand Up @@ -106,6 +112,18 @@ protected function getCacheKey(Request $request)

$key = md5(str_replace($domain, '', $fullUrl));

return $this->normalizeKey($key);
return $this->normalizeKey($this->getGeneration($request).':'.$key);
}

private function getGeneration(Request $request): string
{
$this->generations ??= new WeakMap;

return $this->generations[$request] ??= Cache::rememberForever($this->getGenerationKey(), fn () => Str::random());
}

private function getGenerationKey(): string
{
return $this->normalizeKey('generation');
}
}
20 changes: 19 additions & 1 deletion src/GraphQL/ResponseCache/DefaultCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Statamic\Contracts\GraphQL\ResponseCache;
use Statamic\Events\Event;
use WeakMap;

class DefaultCache implements ResponseCache
{
private ?WeakMap $generations = null;

public function get(Request $request)
{
$cached = Cache::get($this->getCacheKey($request));
Expand Down Expand Up @@ -66,11 +70,25 @@ protected function getCacheKey(Request $request)
$query = $request->input('query');
$vars = $request->input('variables');

return 'gql-cache:'.md5($query).'_'.md5(json_encode($vars));
return 'gql-cache:'.$this->getGeneration($request).':'.md5($query).'_'.md5(json_encode($vars));
}

private function getGeneration(Request $request): string
{
$this->generations ??= new WeakMap;

return $this->generations[$request] ??= Cache::rememberForever($this->getGenerationKey(), fn () => Str::random());
}

private function getGenerationKey(): string
{
return 'gql-cache:generation';
}

public function handleInvalidationEvent(Event $event)
{
Cache::forget($this->getGenerationKey());

$this->getTrackedResponses()->each(function ($key) {
Cache::forget($key);
});
Expand Down
148 changes: 106 additions & 42 deletions tests/API/CacherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
namespace Tests\API;

use Facades\Tests\Factories\EntryFactory;
use Illuminate\Cache\Events\WritingKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event as EventFacade;
use Orchestra\Testbench\Attributes\DefineEnvironment;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Statamic\API\AbstractCacher;
use Statamic\API\Cachers\DefaultCacher;
use Statamic\Events\EntrySaved;
use Statamic\Events\Event;
use Statamic\Facades;
Expand Down Expand Up @@ -50,8 +53,7 @@ public function it_caches_endpoint_using_default_cacher()
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";
$cacheKey = $this->cacheKey($endpoint);

$this->assertTrue(Cache::has($cacheKey));
$this->assertEquals([$cacheKey], Cache::get('api-cache:tracked-responses'));
Expand Down Expand Up @@ -83,8 +85,7 @@ public function it_bypasses_cache_when_using_a_valid_token($endpoint, $headers)
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$this->assertFalse(Cache::has("api-cache:$hash"));
$this->assertFalse(Cache::has($this->cacheKey($endpoint)));
$this->assertNull(Cache::get('api-cache:tracked-responses'));
}

Expand All @@ -104,8 +105,7 @@ public function it_doesnt_bypass_cache_when_using_an_invalid_token($endpoint, $h
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$this->assertTrue(Cache::has($cacheKey = "api-cache:$hash"));
$this->assertTrue(Cache::has($cacheKey = $this->cacheKey($endpoint)));
$this->assertEquals([$cacheKey], Cache::get('api-cache:tracked-responses'));
}

Expand All @@ -126,13 +126,13 @@ public function it_caches_endpoint_using_configured_expiry()
$this->makeEntry('apple')->save();

$endpoint = '/api/collections/articles/entries';
$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";

Carbon::setTestNow(now());

$this->get($endpoint)->assertOk();

$cacheKey = $this->cacheKey($endpoint);

$this->assertTrue(Cache::has($cacheKey));

Carbon::setTestNow(now()->addMinutes(14));
Expand All @@ -151,8 +151,7 @@ public function it_caches_endpoint_with_query_params()
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";
$cacheKey = $this->cacheKey($endpoint);

$this->assertTrue(Cache::has($cacheKey));
$this->assertEquals([$cacheKey], Cache::get('api-cache:tracked-responses'));
Expand All @@ -175,15 +174,12 @@ public function it_caches_multiple_endpoints()
['id' => 'apple'],
]]);

$hashOne = md5($endpointOne);
$hashTwo = md5($endpointTwo);
$this->assertTrue(Cache::has("api-cache:$hashOne"));
$this->assertTrue(Cache::has("api-cache:$hashTwo"));
$keyOne = $this->cacheKey($endpointOne);
$keyTwo = $this->cacheKey($endpointTwo);
$this->assertTrue(Cache::has($keyOne));
$this->assertTrue(Cache::has($keyTwo));

$cachedResponses = [
"api-cache:$hashOne",
"api-cache:$hashTwo",
];
$cachedResponses = [$keyOne, $keyTwo];

$this->assertEquals($cachedResponses, Cache::get('api-cache:tracked-responses'));
}
Expand All @@ -206,16 +202,17 @@ public function it_busts_whole_cache_when_content_is_saved()
['id' => 'apple'],
]]);

$hashOne = md5($endpointOne);
$hashTwo = md5($endpointTwo);
$this->assertTrue(Cache::has("api-cache:$hashOne"));
$this->assertTrue(Cache::has("api-cache:$hashTwo"));
$keyOne = $this->cacheKey($endpointOne);
$keyTwo = $this->cacheKey($endpointTwo);
$this->assertTrue(Cache::has($keyOne));
$this->assertTrue(Cache::has($keyTwo));
$this->assertCount(2, Cache::get('api-cache:tracked-responses'));

$entry->save();

$this->assertFalse(Cache::has("api-cache:$hashOne"));
$this->assertFalse(Cache::has("api-cache:$hashTwo"));
$this->assertFalse(Cache::has($keyOne));
$this->assertFalse(Cache::has($keyTwo));
$this->assertFalse(Cache::has('api-cache:generation'));
$this->assertFalse(Cache::has('api-cache:tracked-responses'));
}

Expand All @@ -236,20 +233,91 @@ public function it_busts_whole_cache_when_unrelated_content_is_saved()
['id' => 'apple'],
]]);

$hashOne = md5($endpointOne);
$hashTwo = md5($endpointTwo);
$keyOne = $this->cacheKey($endpointOne);
$keyTwo = $this->cacheKey($endpointTwo);

$this->assertTrue(Cache::has("api-cache:$hashOne"));
$this->assertTrue(Cache::has("api-cache:$hashTwo"));
$this->assertTrue(Cache::has($keyOne));
$this->assertTrue(Cache::has($keyTwo));
$this->assertCount(2, Cache::get('api-cache:tracked-responses'));

Facades\Form::make('contact')->save();

$this->assertFalse(Cache::has("api-cache:$hashOne"));
$this->assertFalse(Cache::has("api-cache:$hashTwo"));
$this->assertFalse(Cache::has($keyOne));
$this->assertFalse(Cache::has($keyTwo));
$this->assertFalse(Cache::has('api-cache:generation'));
$this->assertFalse(Cache::has('api-cache:tracked-responses'));
}

/**
* @see https://github.com/statamic/cms/issues/15523
*/
#[Test]
public function it_busts_responses_cached_concurrently_when_content_is_saved()
{
$entry = $this->makeEntry('apple');
$entry->save();

$requestOne = Request::create('/api/collections/articles/entries?query=one');
$requestTwo = Request::create('/api/collections/articles/entries?query=two');

$workerOne = new DefaultCacher(['expiry' => 60]);
$workerTwo = new DefaultCacher(['expiry' => 60]);

$workerOne->get($requestOne);
$workerTwo->get($requestTwo);

$workerTwoIsWriting = false;

EventFacade::listen(WritingKey::class, function () use (&$workerTwoIsWriting, $workerTwo, $requestTwo) {
if ($workerTwoIsWriting) {
return;
}

$workerTwoIsWriting = true;
$workerTwo->put($requestTwo, new JsonResponse(['data' => 'two']));
});

$workerOne->put($requestOne, new JsonResponse(['data' => 'one']));

$entry->save();

$this->assertNull((new DefaultCacher(['expiry' => 60]))->get($requestOne));
$this->assertNull((new DefaultCacher(['expiry' => 60]))->get($requestTwo));
}

/**
* @see https://github.com/statamic/cms/issues/15523
*/
#[Test]
public function it_doesnt_cache_a_stale_response_when_content_is_saved_mid_request()
{
$entry = $this->makeEntry('apple');
$entry->save();

$request = Request::create('/api/collections/articles/entries');

$cacher = new DefaultCacher(['expiry' => 60]);
$cacher->get($request);

$cacher->handleInvalidationEvent(new EntrySaved($entry));

$cacher->put($request, new JsonResponse(['data' => 'stale']));

$this->assertNull($cacher->get(Request::create('/api/collections/articles/entries')));
}

#[Test]
public function a_long_lived_cacher_uses_the_current_generation_for_each_request()
{
$cacher = new DefaultCacher(['expiry' => 60]);

$cacher->put(Request::create('/api/collections/articles/entries'), new JsonResponse(['data' => 'old']));

Cache::forget('api-cache:generation');

$this->assertNull($cacher->get(Request::create('/api/collections/articles/entries')));
}

#[Test]
public function it_can_disable_default_cacher_by_setting_false_on_parent_cache_config()
{
Expand All @@ -263,10 +331,7 @@ public function it_can_disable_default_cacher_by_setting_false_on_parent_cache_c
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";

$this->assertFalse(Cache::has($cacheKey));
$this->assertFalse(Cache::has($this->cacheKey($endpoint)));
$this->assertFalse(Cache::has('api-cache:tracked-responses'));
}

Expand All @@ -283,10 +348,7 @@ public function it_can_disable_default_cacher_by_setting_false_on_child_class_co
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";

$this->assertFalse(Cache::has($cacheKey));
$this->assertFalse(Cache::has($this->cacheKey($endpoint)));
$this->assertFalse(Cache::has('api-cache:tracked-responses'));
}

Expand All @@ -304,10 +366,7 @@ public function it_can_use_custom_cacher()
['id' => 'apple'],
]]);

$hash = md5($endpoint);
$cacheKey = "api-cache:$hash";

$this->assertFalse(Cache::has($cacheKey));
$this->assertFalse(Cache::has($this->cacheKey($endpoint)));
$this->assertFalse(Cache::has('api-cache:tracked-responses'));
$this->assertTrue(Cache::has('custom-cache'));

Expand All @@ -320,6 +379,11 @@ public function it_can_use_custom_cacher()
$this->assertFalse(Cache::has('custom-cache'));
}

private function cacheKey($endpoint)
{
return 'api-cache:'.Cache::get('api-cache:generation').':'.md5($endpoint);
}

protected function setCustomExpiry($app)
{
$app->config->set('statamic.api.cache.expiry', 13);
Expand Down
Loading
Loading