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
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ vendor/bin/pest tests/Unit/TrackerTest.php
# Run a specific test by name
vendor/bin/pest --filter "test name"

# Run the whole suite against the redis driver (needs a local redis; uses db 15)
CACHE_TRACKER_TEST_REDIS=1 vendor/bin/pest

# Lint (Laravel Pint)
vendor/bin/pint

Expand All @@ -30,15 +33,17 @@ This is a Statamic addon (`thoughtco/statamic-cache-tracker`) that tracks which

### Core Flow

1. **Tracking** — `Http/Middleware/CacheTracker` runs on every GET request. It hooks into Statamic's augmentation lifecycle (via `Asset::hook`, `Entry::hook`, `LocalizedTerm::hook`, etc.) to collect "tags" for each content item rendered during the request. After a successful response, it stores a `URL → [tags]` mapping in cache.
1. **Tracking** — `Http/Middleware/CacheTracker` runs on every GET request. It hooks into Statamic's augmentation lifecycle (via `Asset::hook`, `Entry::hook`, `LocalizedTerm::hook`, etc.) to collect "tags" for each content item rendered during the request. After a successful response, it stores a `URL → [tags]` mapping via the tracker store.

2. **Invalidation** — `Listeners/Subscriber` subscribes to Statamic save/delete events (entries, assets, terms, navs, forms, globals). When content changes, it dispatches `Jobs/InvalidateTags` which calls `Tracker::invalidate($tags)` to find all URLs containing those tags and clear them from the static cache.

3. **Tag format** — Tags follow patterns like `asset:{id}`, `collection:{handle}:{id}`, `term:{id}`, `form:{handle}`, `nav:{handle}`, `global:{handle}`. Wildcard matching is supported via `*` prefix notation (e.g., `collection:blog:*`).

### Key Classes

- **`Tracker/Manager`** — Core logic. Stores `URL → tags[]` in cache under key `tracker::urls`. Handles `add`, `get`, `invalidate`, and `flush`. Tag matching supports wildcards.
- **`Tracker/Manager`** — Core logic. Handles `add`, `get`, `invalidate`, and `flush`, delegating storage to a `Tracker/Stores/Store` on the `static_cache` cache store (or the default store): `RedisStore` when that store uses Redis, otherwise `CacheStore`. On first use it migrates the legacy single-key `tracker::urls` blob.
- **`Tracker/Stores/RedisStore`** — Native Redis: a hash of URL records plus one set per tag. Single-key commands only (cluster safe).
- **`Tracker/Stores/CacheStore`** — Any Laravel cache store: one key per URL, 256 sharded URL index keys, and one bucket per tag. Bucket writes are locked; stale bucket entries are verified and pruned at lookup time. Wildcard tags fall back to a full scan.
- **`Http/Middleware/CacheTracker`** — Hooks into Statamic augmentation to collect tags during rendering. Also fires `TrackContentTags` event for custom integrations.
- **`Listeners/Subscriber`** — Maps Statamic events to tag generation and queues invalidation.
- **`Facades/Tracker`** — Facade for `Manager` (bound as `cache-tracker`).
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/ListUrls.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ListUrls extends Command

public function handle()
{
$all = collect(Tracker::all());
$all = collect(Tracker::all())->sortBy('url');

if ($filter = $this->option('url')) {
$prefix = rtrim($filter, '*');
Expand Down
3 changes: 1 addition & 2 deletions src/Http/Controllers/GetUrlsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ public function __invoke(): array
$item = 'term:'.$item->id();
}

return collect(Tracker::all())
->filter(fn ($tracked) => in_array($item, $tracked['tags']))
return collect(Tracker::findByTags([$item]))
->pluck('url')
->values()
->all();
Expand Down
7 changes: 7 additions & 0 deletions src/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class ServiceProvider extends AddonServiceProvider
'hotFile' => __DIR__.'/../dist/hot',
];

public function register()
{
parent::register();

$this->app->singleton(Tracker\Manager::class);
}

public function boot()
{
parent::boot();
Expand Down
146 changes: 90 additions & 56 deletions src/Tracker/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@
namespace Thoughtco\StatamicCacheTracker\Tracker;

use Closure;
use Illuminate\Support\Arr;
use Illuminate\Cache\RedisStore as IlluminateRedisStore;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Support\Facades\Cache;
use InvalidArgumentException;
use Statamic\StaticCaching\Cacher;
use Thoughtco\StatamicCacheTracker\Events\ContentTracked;
use Thoughtco\StatamicCacheTracker\Tracker\Stores\CacheStore;
use Thoughtco\StatamicCacheTracker\Tracker\Stores\RedisStore;
use Thoughtco\StatamicCacheTracker\Tracker\Stores\Store;

class Manager
{
private string $cacheKey = 'tracker::urls';
/**
* Older versions kept every URL in one array under this key.
*/
public const LEGACY_CACHE_KEY = 'tracker::urls';

private array $pipelines = [];

private ?Store $store = null;

public function add(string $url, array $tags = [])
{
$storeData = $this->all();
$storeData[md5($url)] = [
'url' => $url,
'tags' => collect($tags)->unique()->values()->all(),
];

$this->cacheStore()->forever($this->cacheKey, $storeData);
$this->store()->put(md5($url), $url, collect($tags)->unique()->values()->all());

ContentTracked::dispatch($url, $tags);

Expand All @@ -43,7 +46,7 @@ public function addAdditionalTracker(Closure|string $class)

public function all()
{
return $this->cacheStore()->get($this->cacheKey) ?? [];
return $this->store()->all();
}

public function cacheStore()
Expand All @@ -57,9 +60,34 @@ public function cacheStore()
return $store;
}

public function store(): Store
{
if ($this->store) {
return $this->store;
}

$this->store = $this->resolveStore();

$this->migrateLegacyData();

return $this->store;
}

private function resolveStore(): Store
{
$cache = $this->cacheStore();
$store = $cache->getStore();

if ($store instanceof IlluminateRedisStore) {
return new RedisStore($store->connection(), $store->getPrefix().'cache-tracker:');
}

return new CacheStore($cache);
}

public function get(string $url)
{
return $this->all()[md5($url)] ?? null;
return $this->store()->get(md5($url));
}

public function getAdditionalTrackers()
Expand All @@ -69,63 +97,39 @@ public function getAdditionalTrackers()

public function has(string $url)
{
return Arr::exists($this->all(), md5($url));
return $this->get($url) !== null;
}

/**
* Get the tracked records with a tag matching any of the given tags.
*/
public function findByTags(array $tags): array
{
return $this->store()->matching($tags);
}

public function invalidate(array $tags = [])
{
$storeData = $this->all();
$matches = $this->store()->matching($tags);

if (empty($matches)) {
return $this;
}

// With background recaching the page is refreshed in place rather than
// deleted, and the recache request re-tracks the URL with fresh tags. We
// keep the existing entry so that if the recache job fails or is dropped
// the URL stays tracked and future saves will retry it, instead of being
// orphaned in the static cache with no tags.
$keepEntries = config('statamic.static_caching.background_recache', false);

$urls = [];
foreach ($storeData as $key => $data) {
if (! $this->tagsMatch($tags, $data['tags'])) {
continue;
}

$urls[] = $data['url'];

if (! $keepEntries) {
unset($storeData[$key]);
}
if (! config('statamic.static_caching.background_recache', false)) {
$this->store()->forget(array_keys($matches));
}

if (! empty($urls)) {
if (! $keepEntries) {
$this->cacheStore()->forever($this->cacheKey, $storeData);
}

$this->invalidateUrls($urls);
}
$this->invalidateUrls(array_values(array_column($matches, 'url')));

return $this;
}

private function tagsMatch(array $tagsToInvalidate, array $storeTags): bool
{
foreach ($tagsToInvalidate as $tag) {
// Handle wildcard tags (ending with *)
if (str_ends_with($tag, '*')) {
$prefix = substr($tag, 0, -1);
foreach ($storeTags as $storeTag) {
if (str_starts_with($storeTag, $prefix)) {
return true;
}
}
} elseif (in_array($tag, $storeTags)) {
return true;
}
}

return false;
}

private function invalidateUrls($urls)
{
$cacher = app(Cacher::class);
Expand All @@ -145,15 +149,45 @@ public function flush()

$this->invalidateUrls($urls);

$this->cacheStore()->forever($this->cacheKey, []);
$this->store()->flush();
}

public function remove(string $url)
{
$this->invalidateUrls([$url]);

$storeData = $this->all();
unset($storeData[md5($url)]);
$this->cacheStore()->forever($this->cacheKey, $storeData);
$this->store()->forget([md5($url)]);
}

/**
* Move data from the old single-key format into the current store.
*/
private function migrateLegacyData(): void
{
$cache = $this->cacheStore();

if (! $cache->has(self::LEGACY_CACHE_KEY)) {
return;
}

$migrate = function () use ($cache) {
$records = collect($cache->get(self::LEGACY_CACHE_KEY) ?? [])
->filter(fn ($record) => is_array($record) && isset($record['url'], $record['tags']))
->map(fn ($record) => ['url' => $record['url'], 'tags' => array_values($record['tags'])])
->all();

$this->store->import($records);

$cache->forget(self::LEGACY_CACHE_KEY);
};

if (! $cache->getStore() instanceof LockProvider) {
$migrate();

return;
}

// Only one process migrates; the others carry on without waiting.
$cache->getStore()->lock(self::LEGACY_CACHE_KEY.':migrating', 600)->get($migrate);
}
}
Loading
Loading