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
1 change: 1 addition & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
'cache_utility_image_cache_description' => 'The image cache stores copies of all transformed and resized images.',
'cache_utility_stache_description' => 'The Stache is Statamic\'s content store that functions much like a database. It is generated automatically from the content files.',
'cache_utility_static_cache_description' => 'Static pages bypass Statamic completely and are rendered directly from the server for maximum performance.',
'cache_utility_static_cache_invalidate_urls_locked' => 'Could not invalidate URLs because the cache was locked by another process. Please try again.',
'choose_entry_localization_deletion_behavior' => 'Choose the action you wish to perform on the localized entries.',
'collection_configure_date_behavior_private' => 'Private - Hidden from listings, URLs return 404',
'collection_configure_date_behavior_public' => 'Public - Always visible',
Expand Down
7 changes: 6 additions & 1 deletion src/Http/Controllers/CP/Utilities/CacheController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\Http\Controllers\CP\Utilities;

use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Inertia\Inertia;
Expand Down Expand Up @@ -133,7 +134,11 @@ public function invalidateStaticUrls(Request $request)
...$prefixedRelativeUrls,
];

app(Cacher::class)->invalidateUrls($urls);
try {
app(Cacher::class)->invalidateUrls($urls);
} catch (LockTimeoutException $e) {
return back()->withError(__('statamic::messages.cache_utility_static_cache_invalidate_urls_locked'));
}

return back()->withSuccess(__('Invalidated URLs in the Static Cache.'));
}
Expand Down
213 changes: 186 additions & 27 deletions src/StaticCaching/Cachers/AbstractCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
namespace Statamic\StaticCaching\Cachers;

use GuzzleHttp\Psr7\Request as GuzzleRequest;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Statamic\Console\Commands\StaticWarmJob;
use Statamic\Events\UrlInvalidated;
use Statamic\Facades\Site;
use Statamic\Facades\URL;
use Statamic\StaticCaching\Cacher;
Expand All @@ -17,6 +19,16 @@

abstract class AbstractCacher implements Cacher
{
/**
* Seconds until a held lock auto-expires, as a crash safety net.
*/
protected const LOCK_TIMEOUT = 10;

/**
* Seconds a caller will wait to acquire a lock before giving up.
*/
protected const LOCK_WAIT = 5;

/**
* @var Repository
*/
Expand All @@ -27,6 +39,16 @@ abstract class AbstractCacher implements Cacher
*/
private $config;

/**
* Lock keys currently held by this instance, so nested withLock() calls
* for the same key (e.g. forgetUrl() called from code already holding the
* domain's urls lock) don't try to re-acquire a lock against themselves
* and deadlock until LOCK_WAIT expires.
*
* @var array<string, bool>
*/
private $heldLocks = [];

public function __construct(Repository $cache, $config)
{
$this->cache = $cache;
Expand Down Expand Up @@ -125,13 +147,17 @@ public function getDomains()
*/
public function cacheDomain($domain = null)
{
$domains = $this->getDomains();
$domain = $domain ?? $this->getBaseUrl();

if (! $domains->contains($domain = $domain ?? $this->getBaseUrl())) {
$domains->push($domain);
}
$this->withLock($this->normalizeKey('domains:lock'), function () use ($domain) {
$domains = $this->getDomains();

$this->cache->forever($this->normalizeKey('domains'), $domains->all());
if (! $domains->contains($domain)) {
$domains->push($domain);
}

$this->cache->forever($this->normalizeKey('domains'), $domains->all());
});
}

/**
Expand All @@ -155,7 +181,9 @@ public function getUrls($domain = null)
public function flushUrls()
{
$this->getDomains()->each(function ($domain) {
$this->cache->forget($this->getUrlsCacheKey($domain));
$this->withLock($this->getUrlsLockKey($domain), function () use ($domain) {
$this->cache->forget($this->getUrlsCacheKey($domain));
});
});

$this->cache->forget($this->normalizeKey('domains'));
Expand All @@ -174,13 +202,15 @@ public function cacheUrl($key, $url, $domain = null)

$this->cacheDomain($domain);

$urls = $this->getUrls($domain);
$this->withLock($this->getUrlsLockKey($domain), function () use ($key, $url, $domain) {
$urls = $this->getUrls($domain);

$url = Str::removeLeft($url, $domain);
$url = Str::removeLeft($url, $domain);

$urls->put($key, $url);
$urls->put($key, $url);

$this->cache->forever($this->getUrlsCacheKey($domain), $urls->all());
$this->cache->forever($this->getUrlsCacheKey($domain), $urls->all());
});
}

/**
Expand All @@ -191,11 +221,69 @@ public function cacheUrl($key, $url, $domain = null)
*/
public function forgetUrl($key, $domain = null)
{
$urls = $this->getUrls($domain);
$this->withLock($this->getUrlsLockKey($domain), function () use ($key, $domain) {
$urls = $this->getUrls($domain);

$urls->forget($key);

$this->cache->forever($this->getUrlsCacheKey($domain), $urls->all());
});
}

$urls->forget($key);
/**
* Run a callback while holding an exclusive lock for the given key, if the
* configured cache store supports locking. Falls back to running the
* callback unprotected if it doesn't, so cache stores without lock
* support (e.g. some third-party addons) don't hard crash.
*
* @return mixed
*/
protected function withLock(string $key, \Closure $callback)
{
if (isset($this->heldLocks[$key])) {
return $callback();
}

if (! $this->cache->getStore() instanceof LockProvider) {
return $callback();
}

return $this->cache->lock($key, static::LOCK_TIMEOUT)->block(static::LOCK_WAIT, function () use ($key, $callback) {
$this->heldLocks[$key] = true;

try {
return $callback();
} finally {
unset($this->heldLocks[$key]);
}
});
}

/**
* @param string|null $domain
* @return string
*/
protected function getUrlsLockKey($domain = null)
{
return $this->getUrlsCacheKey($domain).':lock';
}

/**
* Invalidate a URL.
*
* @param string $url
* @param string|null $domain
* @return void
*/
public function invalidateUrl($url, $domain = null)
{
// For CLI contexts where Site::current()->url() may return the wrong
// domain causing getUrls() to look under the wrong cache key.
if ($domain === null) {
[$url, $domain] = $this->getPathAndDomain($url);
}

$this->cache->forever($this->getUrlsCacheKey($domain), $urls->all());
$this->invalidatePathsForDomain([$url], $domain);
}

/**
Expand All @@ -205,16 +293,9 @@ public function forgetUrl($key, $domain = null)
*/
protected function invalidateWildcardUrl($wildcard)
{
// Remove the asterisk
$wildcard = substr($wildcard, 0, -1);

[$wildcard, $domain] = $this->getPathAndDomain($wildcard);
[, $domain] = $this->getPathAndDomain(substr($wildcard, 0, -1));

$this->getUrls($domain)->filter(function ($url) use ($wildcard) {
return Str::startsWith($url, $wildcard);
})->each(function ($url) use ($domain) {
$this->invalidateUrl($url, $domain);
});
$this->invalidatePathsForDomain([$wildcard], $domain);
}

/**
Expand All @@ -225,15 +306,93 @@ protected function invalidateWildcardUrl($wildcard)
*/
public function invalidateUrls($urls)
{
collect($urls)->each(function ($url) {
if (Str::contains($url, '*')) {
$this->invalidateWildcardUrl($url);
} else {
$this->invalidateUrl(...$this->getPathAndDomain($url));
collect($urls)
->groupBy(fn ($url) => $this->resolveDomainForInvalidation($url))
->each(fn ($urlsForDomain, $domain) => $this->invalidatePathsForDomain($urlsForDomain->all(), $domain));
}

/**
* Invalidate a set of paths (and trailing-* wildcards) for a single domain.
*
* Two phases, so the urls lock is only held for map bookkeeping and never
* for driver cleanup (file deletes, cache forgets) or event listeners:
*
* 1. Under the domain's urls lock: resolve which map entries match, remove
* them, and persist the map in a single write.
* 2. After releasing the lock: driver cleanup and UrlInvalidated events.
*
* A page cached concurrently with phase two can at worst leave a map entry
* whose cached copy was just deleted, which is self-healing: the next
* request re-renders and re-caches under the same key. The reverse - a
* cached copy the map doesn't know about - cannot happen.
*
* @param array $urls
* @param string|null $domain
* @return void
*/
protected function invalidatePathsForDomain($urls, $domain)
{
$paths = collect($urls)->map(function ($url) {
$wildcard = Str::contains($url, '*');

[$path] = $this->getPathAndDomain($wildcard ? substr($url, 0, -1) : $url);

return ['path' => $path, 'wildcard' => $wildcard];
});

$invalidated = $this->withLock($this->getUrlsLockKey($domain), function () use ($paths, $domain) {
$urls = $this->getUrls($domain);

$invalidated = $urls->filter(fn ($value) => $paths->contains(fn ($path) => $path['wildcard']
? Str::startsWith($value, $path['path'])
: ($value === $path['path'] || Str::startsWith($value, $path['path'].'?'))));

if ($invalidated->isNotEmpty()) {
$this->cache->forever($this->getUrlsCacheKey($domain), $urls->diffKeys($invalidated)->all());
}

return $invalidated;
});

$this->cleanupInvalidatedUrls($invalidated, $paths, $domain);

$paths->each(function ($path) use ($invalidated, $domain) {
$path['wildcard']
? $invalidated
->filter(fn ($value) => Str::startsWith($value, $path['path']))
->each(fn ($value) => UrlInvalidated::dispatch($value, $domain))
: UrlInvalidated::dispatch($path['path'], $domain);
});
}

/**
* Clean up the driver's stored copies of invalidated pages. Runs after the
* urls lock has been released.
*
* @param \Illuminate\Support\Collection $invalidated The removed entries, keyed by their urls map key.
* @param \Illuminate\Support\Collection $paths The ['path' => string, 'wildcard' => bool] entries the invalidation was requested with.
* @param string|null $domain
* @return void
*/
abstract protected function cleanupInvalidatedUrls($invalidated, $paths, $domain);

/**
* Resolve the domain an invalidation entry belongs to, the same way
* invalidateUrl()/invalidateWildcardUrl() would internally, so entries can
* be grouped by domain before either is called.
*
* @param string $url
* @return string
*/
protected function resolveDomainForInvalidation($url)
{
$url = Str::contains($url, '*') ? substr($url, 0, -1) : $url;

[, $domain] = $this->getPathAndDomain($url);

return $domain;
}

/**
* Refresh multiple URLs.
*
Expand Down
Loading
Loading