diff --git a/CLAUDE.md b/CLAUDE.md index 5ee0528..d704224 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -30,7 +33,7 @@ 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. @@ -38,7 +41,9 @@ This is a Statamic addon (`thoughtco/statamic-cache-tracker`) that tracks which ### 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`). diff --git a/src/Commands/ListUrls.php b/src/Commands/ListUrls.php index 63ab9a3..c9c9f9b 100644 --- a/src/Commands/ListUrls.php +++ b/src/Commands/ListUrls.php @@ -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, '*'); diff --git a/src/Http/Controllers/GetUrlsController.php b/src/Http/Controllers/GetUrlsController.php index 5013d3f..84883df 100644 --- a/src/Http/Controllers/GetUrlsController.php +++ b/src/Http/Controllers/GetUrlsController.php @@ -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(); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 6b930fa..4aefa55 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -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(); diff --git a/src/Tracker/Manager.php b/src/Tracker/Manager.php index 82b9ec6..82b45b1 100644 --- a/src/Tracker/Manager.php +++ b/src/Tracker/Manager.php @@ -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); @@ -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() @@ -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() @@ -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); @@ -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); } } diff --git a/src/Tracker/Stores/CacheStore.php b/src/Tracker/Stores/CacheStore.php new file mode 100644 index 0000000..e61f2fc --- /dev/null +++ b/src/Tracker/Stores/CacheStore.php @@ -0,0 +1,260 @@ + ..., 'tags' => [...]] for one URL + * - tracker::index:{xx} [key => url], sharded 256 ways by md5(key) + * - tracker::tag:{md5} [key => true] for every URL that was tracked with the tag + * + * Tag buckets are only ever added to when tracking. Entries for URLs that have + * since been removed or re-tracked without the tag are verified against the + * URL record on lookup and pruned then, so a stale entry never causes a URL to + * be invalidated and adding to a bucket never needs to know a URL's old tags. + */ +class CacheStore implements Store +{ + private const CHUNK = 500; + + private const LOCK_SECONDS = 10; + + private const LOCK_WAIT = 5; + + public function __construct(private Repository $cache) {} + + public function get(string $key): ?array + { + return $this->cache->get($this->urlKey($key)); + } + + public function put(string $key, string $url, array $tags): void + { + $this->cache->forever($this->urlKey($key), ['url' => $url, 'tags' => $tags]); + + $this->addToSet($this->indexKey($key), [$key => $url]); + + foreach ($tags as $tag) { + $this->addToSet($this->tagKey($tag), [$key => true]); + } + } + + public function import(array $records): void + { + $existing = $this->many(array_keys($records)); + + $index = []; + $buckets = []; + + foreach ($records as $key => $record) { + if (isset($existing[$key])) { + continue; + } + + $this->cache->forever($this->urlKey($key), $record); + + $index[$this->indexKey($key)][$key] = $record['url']; + + foreach ($record['tags'] as $tag) { + $buckets[$this->tagKey($tag)][$key] = true; + } + } + + foreach ($index + $buckets as $setKey => $members) { + $this->addToSet($setKey, $members); + } + } + + public function forget(array $keys): void + { + $byShard = []; + + foreach ($keys as $key) { + $this->cache->forget($this->urlKey($key)); + $byShard[$this->indexKey($key)][] = $key; + } + + foreach ($byShard as $shard => $members) { + $this->locked($shard, function () use ($shard, $members) { + $index = $this->cache->get($shard) ?? []; + $remaining = array_diff_key($index, array_flip($members)); + + if (count($remaining) !== count($index)) { + $this->cache->forever($shard, $remaining); + } + }); + } + } + + public function all(): array + { + $keys = []; + + foreach ($this->cache->many($this->indexKeys()) as $index) { + foreach ($index ?? [] as $key => $url) { + $keys[] = $key; + } + } + + return $this->many($keys); + } + + public function matching(array $tags): array + { + [$exact, $prefixes] = TagMatcher::split($tags); + + // Wildcards can't be answered from the tag buckets, so fall back to a scan. + if (! empty($prefixes)) { + return array_filter($this->all(), fn ($record) => TagMatcher::matches($tags, $record['tags'])); + } + + $buckets = []; + foreach ($exact as $tag) { + $buckets[$tag] = array_keys($this->cache->get($this->tagKey($tag)) ?? []); + } + + $records = $this->many(array_unique(array_merge(...array_values($buckets ?: [[]])))); + + foreach ($buckets as $tag => $keys) { + $stale = array_filter($keys, fn ($key) => ! $this->recordHasTag($records[$key] ?? null, $tag)); + + if (! empty($stale)) { + $this->prune($tag, $stale); + } + } + + return array_filter($records, fn ($record) => TagMatcher::matches($exact, $record['tags'])); + } + + public function flush(): void + { + $records = $this->all(); + + $tags = []; + foreach ($records as $record) { + foreach ($record['tags'] as $tag) { + $tags[$tag] = true; + } + } + + foreach (array_keys($tags) as $tag) { + $this->cache->forget($this->tagKey($tag)); + } + + foreach (array_keys($records) as $key) { + $this->cache->forget($this->urlKey($key)); + } + + foreach ($this->indexKeys() as $shard) { + $this->cache->forget($shard); + } + } + + /** + * Remove stale keys from a tag bucket, re-checking each one inside the lock + * so a URL that was re-tracked with the tag in the meantime is kept. + */ + private function prune(string $tag, array $stale): void + { + $bucketKey = $this->tagKey($tag); + + $this->locked($bucketKey, function () use ($bucketKey, $tag, $stale) { + $bucket = $this->cache->get($bucketKey) ?? []; + $current = $this->many($stale); + + foreach ($stale as $key) { + if (! $this->recordHasTag($current[$key] ?? null, $tag)) { + unset($bucket[$key]); + } + } + + empty($bucket) + ? $this->cache->forget($bucketKey) + : $this->cache->forever($bucketKey, $bucket); + }); + } + + private function addToSet(string $setKey, array $members): void + { + // Most tracking is of URLs/tags already in the set, so check without + // the lock first and skip the write entirely when nothing changes. + $set = $this->cache->get($setKey) ?? []; + + if (array_intersect_assoc($members, $set) === $members) { + return; + } + + $this->locked($setKey, function () use ($setKey, $members) { + $set = $this->cache->get($setKey) ?? []; + $this->cache->forever($setKey, array_replace($set, $members)); + }); + } + + private function locked(string $key, callable $callback): mixed + { + $store = $this->cache->getStore(); + + if (! $store instanceof LockProvider) { + return $callback(); + } + + try { + return $store->lock($key.':lock', self::LOCK_SECONDS)->block(self::LOCK_WAIT, $callback); + } catch (LockTimeoutException) { + // Better to risk a lost update than to fail the request. + return $callback(); + } + } + + /** + * @return array records keyed by URL key, missing ones omitted + */ + private function many(array $keys): array + { + $records = []; + + foreach (array_chunk(array_values($keys), self::CHUNK) as $chunk) { + $values = $this->cache->many(array_map(fn ($key) => $this->urlKey($key), $chunk)); + + foreach ($chunk as $key) { + if ($record = $values[$this->urlKey($key)] ?? null) { + $records[$key] = $record; + } + } + } + + return $records; + } + + private function recordHasTag(?array $record, string $tag): bool + { + return $record && in_array($tag, $record['tags']); + } + + private function indexKeys(): array + { + return array_map(fn ($i) => 'tracker::index:'.sprintf('%02x', $i), range(0, 255)); + } + + private function urlKey(string $key): string + { + return 'tracker::url:'.$key; + } + + private function indexKey(string $key): string + { + return 'tracker::index:'.substr(md5($key), 0, 2); + } + + private function tagKey(string $tag): string + { + return 'tracker::tag:'.md5($tag); + } +} diff --git a/src/Tracker/Stores/RedisStore.php b/src/Tracker/Stores/RedisStore.php new file mode 100644 index 0000000..1b653fe --- /dev/null +++ b/src/Tracker/Stores/RedisStore.php @@ -0,0 +1,226 @@ + json {url, tags} + * - cache-tracker:tag:{tag} set of URL keys tracked with the tag + * - cache-tracker:tags set of every tag seen (used for wildcard lookups) + * + * Every command touches a single key, so this also works on Redis Cluster. + */ +class RedisStore implements Store +{ + private const CHUNK = 1000; + + public function __construct(private Connection $redis, private string $prefix) {} + + public function get(string $key): ?array + { + return $this->decode($this->redis->hget($this->urlsKey(), $key)); + } + + public function put(string $key, string $url, array $tags): void + { + $old = $this->get($key); + $removed = $old ? array_diff($old['tags'], $tags) : []; + + $this->batch(function ($redis) use ($key, $url, $tags, $removed) { + $redis->hset($this->urlsKey(), $key, $this->encode($url, $tags)); + + foreach ($tags as $tag) { + $redis->sadd($this->tagKey($tag), $key); + } + + if (! empty($tags)) { + $redis->sadd($this->tagsKey(), ...$tags); + } + + foreach ($removed as $tag) { + $redis->srem($this->tagKey($tag), $key); + } + }); + } + + public function import(array $records): void + { + foreach (array_chunk($records, self::CHUNK, true) as $chunk) { + $existing = $this->many(array_keys($chunk)); + $chunk = array_diff_key($chunk, $existing); + + if (empty($chunk)) { + continue; + } + + $byTag = []; + foreach ($chunk as $key => $record) { + foreach ($record['tags'] as $tag) { + $byTag[$tag][] = $key; + } + } + + $this->batch(function ($redis) use ($chunk, $byTag) { + foreach ($chunk as $key => $record) { + $redis->hset($this->urlsKey(), $key, $this->encode($record['url'], $record['tags'])); + } + + foreach ($byTag as $tag => $keys) { + $redis->sadd($this->tagKey($tag), ...$keys); + } + + if (! empty($byTag)) { + $redis->sadd($this->tagsKey(), ...array_map('strval', array_keys($byTag))); + } + }); + } + } + + public function forget(array $keys): void + { + foreach (array_chunk(array_values($keys), self::CHUNK) as $chunk) { + $records = $this->many($chunk); + + $byTag = []; + foreach ($records as $key => $record) { + foreach ($record['tags'] as $tag) { + $byTag[$tag][] = $key; + } + } + + $this->batch(function ($redis) use ($chunk, $byTag) { + $redis->hdel($this->urlsKey(), ...$chunk); + + foreach ($byTag as $tag => $keys) { + $redis->srem($this->tagKey($tag), ...$keys); + } + }); + } + } + + public function all(): array + { + $records = []; + + foreach ($this->redis->hgetall($this->urlsKey()) ?: [] as $key => $value) { + if ($record = $this->decode($value)) { + $records[$key] = $record; + } + } + + return $records; + } + + public function matching(array $tags): array + { + [$exact, $prefixes] = TagMatcher::split($tags); + + if (! empty($prefixes)) { + foreach ($this->redis->smembers($this->tagsKey()) ?: [] as $known) { + foreach ($prefixes as $prefix) { + if (str_starts_with($known, $prefix)) { + $exact[] = $known; + break; + } + } + } + } + + $keys = []; + foreach (array_unique($exact) as $tag) { + foreach ($this->redis->smembers($this->tagKey($tag)) ?: [] as $key) { + $keys[$key] = true; + } + } + + $records = $this->many(array_keys($keys)); + + return array_filter($records, fn ($record) => TagMatcher::matches($tags, $record['tags'])); + } + + public function flush(): void + { + $tags = $this->redis->smembers($this->tagsKey()) ?: []; + + foreach (array_chunk($tags, self::CHUNK) as $chunk) { + $this->batch(function ($redis) use ($chunk) { + foreach ($chunk as $tag) { + $redis->del($this->tagKey($tag)); + } + }); + } + + $this->redis->del($this->urlsKey()); + $this->redis->del($this->tagsKey()); + } + + private function many(array $keys): array + { + $records = []; + + foreach (array_chunk(array_values($keys), self::CHUNK) as $chunk) { + $values = $this->redis->hmget($this->urlsKey(), $chunk); + + foreach (array_values($values) as $i => $value) { + if ($record = $this->decode($value)) { + $records[$chunk[$i]] = $record; + } + } + } + + return $records; + } + + /** + * Pipeline the commands where the connection supports it. Cluster + * connections don't pipeline across slots, so send them one by one. + */ + private function batch(callable $callback): void + { + if ($this->redis instanceof PhpRedisClusterConnection || $this->redis instanceof PredisClusterConnection) { + $callback($this->redis); + + return; + } + + $this->redis->pipeline($callback); + } + + private function encode(string $url, array $tags): string + { + return json_encode(['url' => $url, 'tags' => array_values($tags)], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function decode(mixed $value): ?array + { + if (! is_string($value) || $value === '') { + return null; + } + + $record = json_decode($value, true); + + return is_array($record) ? $record : null; + } + + private function urlsKey(): string + { + return $this->prefix.'urls'; + } + + private function tagsKey(): string + { + return $this->prefix.'tags'; + } + + private function tagKey(string $tag): string + { + return $this->prefix.'tag:'.$tag; + } +} diff --git a/src/Tracker/Stores/Store.php b/src/Tracker/Stores/Store.php new file mode 100644 index 0000000..a44702d --- /dev/null +++ b/src/Tracker/Stores/Store.php @@ -0,0 +1,42 @@ + string, 'tags' => string[]]. + */ + public function get(string $key): ?array; + + /** + * Store the tags for a URL, replacing any existing record for it. + */ + public function put(string $key, string $url, array $tags): void; + + /** + * Bulk import records keyed by URL key. Existing records are left untouched. + */ + public function import(array $records): void; + + /** + * Remove the records for the given URL keys. + */ + public function forget(array $keys): void; + + /** + * Get every record, keyed by URL key. + */ + public function all(): array; + + /** + * Get the records with at least one tag matching the given tags (which may + * contain wildcards), keyed by URL key. + */ + public function matching(array $tags): array; + + /** + * Remove every record. + */ + public function flush(): void; +} diff --git a/src/Tracker/TagMatcher.php b/src/Tracker/TagMatcher.php new file mode 100644 index 0000000..8d8914b --- /dev/null +++ b/src/Tracker/TagMatcher.php @@ -0,0 +1,45 @@ +set('statamic.static_caching.strategy', 'half'); + // Run the whole suite against redis with CACHE_TRACKER_TEST_REDIS=1 + static::configureRedis($app); + + if (static::usesRedis()) { + $app['config']->set('cache.stores.static_cache', [ + 'driver' => 'redis', + 'connection' => 'cache_tracker_tests', + 'lock_connection' => 'cache_tracker_tests', + ]); + } + // views for front end routing tests $app['config']->set('view.paths', [ __DIR__.'/__fixtures__/resources/views', ]); } + public static function usesRedis(): bool + { + return (bool) getenv('CACHE_TRACKER_TEST_REDIS'); + } + + public static function configureRedis($app): void + { + $app['config']->set('database.redis.cache_tracker_tests', [ + 'host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'port' => getenv('REDIS_PORT') ?: 6379, + 'database' => 15, + ]); + } + + protected function flushRedis(): void + { + Redis::connection('cache_tracker_tests')->flushdb(); + } + protected function setUp(): void { parent::setUp(); + if (static::usesRedis()) { + $this->flushRedis(); + } + Facades\Collection::make('pages') ->title('Pages') ->routes('{parent_uri}/{slug}') diff --git a/tests/Unit/StoreTest.php b/tests/Unit/StoreTest.php new file mode 100644 index 0000000..3bf9474 --- /dev/null +++ b/tests/Unit/StoreTest.php @@ -0,0 +1,179 @@ + ['cache'], + 'redis' => ['redis'], + ]; + } + + private function useDriver(string $driver): Store + { + if ($driver === 'redis') { + try { + Redis::connection('cache_tracker_tests')->ping(); + } catch (\Throwable $e) { + $this->markTestSkipped('Redis is not available: '.$e->getMessage()); + } + + $this->flushRedis(); + + config(['cache.stores.static_cache' => [ + 'driver' => 'redis', + 'connection' => 'cache_tracker_tests', + 'lock_connection' => 'cache_tracker_tests', + ]]); + } else { + config(['cache.stores.static_cache' => ['driver' => 'array']]); + } + + Cache::forgetDriver('static_cache'); + $this->app->forgetInstance(Manager::class); + Tracker::clearResolvedInstance(Manager::class); + + return Tracker::store(); + } + + #[Test] + #[DataProvider('drivers')] + public function it_resolves_the_driver_from_the_cache_store(string $driver) + { + $store = $this->useDriver($driver); + + $this->assertInstanceOf($driver === 'redis' ? RedisStore::class : CacheStore::class, $store); + } + + #[Test] + #[DataProvider('drivers')] + public function it_stores_and_forgets_urls(string $driver) + { + $store = $this->useDriver($driver); + + $store->put('a', '/a', ['one', 'two']); + $store->put('b', '/b', ['two']); + + $this->assertSame(['url' => '/a', 'tags' => ['one', 'two']], $store->get('a')); + $this->assertCount(2, $store->all()); + + $store->forget(['a']); + + $this->assertNull($store->get('a')); + $this->assertSame(['b'], array_keys($store->all())); + $this->assertSame(['b'], array_keys($store->matching(['two']))); + $this->assertSame([], $store->matching(['one'])); + } + + #[Test] + #[DataProvider('drivers')] + public function it_does_not_match_tags_a_url_no_longer_has(string $driver) + { + $store = $this->useDriver($driver); + + $store->put('a', '/a', ['old', 'kept']); + $store->put('a', '/a', ['new', 'kept']); + + $this->assertSame([], $store->matching(['old'])); + $this->assertSame(['a'], array_keys($store->matching(['new']))); + $this->assertSame(['a'], array_keys($store->matching(['kept']))); + + // re-tracking with the old tag again matches again + $store->put('a', '/a', ['old']); + $this->assertSame(['a'], array_keys($store->matching(['old']))); + } + + #[Test] + #[DataProvider('drivers')] + public function it_matches_wildcards_on_any_prefix(string $driver) + { + $store = $this->useDriver($driver); + + $store->put('a', '/a', ['products:1']); + $store->put('b', '/b', ['product-lines:2']); + $store->put('c', '/c', ['articles:1']); + + $this->assertEqualsCanonicalizing(['a'], array_keys($store->matching(['products:*']))); + $this->assertEqualsCanonicalizing(['a', 'b'], array_keys($store->matching(['product*']))); + $this->assertEqualsCanonicalizing(['a', 'b', 'c'], array_keys($store->matching(['*']))); + $this->assertEqualsCanonicalizing(['b', 'c'], array_keys($store->matching(['product-*', 'articles:1']))); + } + + #[Test] + #[DataProvider('drivers')] + public function it_imports_without_overwriting_existing_records(string $driver) + { + $store = $this->useDriver($driver); + + $store->put('a', '/a', ['fresh']); + + $store->import([ + 'a' => ['url' => '/a', 'tags' => ['stale']], + 'b' => ['url' => '/b', 'tags' => ['imported']], + ]); + + $this->assertSame(['fresh'], $store->get('a')['tags']); + $this->assertSame([], $store->matching(['stale'])); + $this->assertSame(['b'], array_keys($store->matching(['imported']))); + } + + #[Test] + #[DataProvider('drivers')] + public function it_flushes_everything(string $driver) + { + $store = $this->useDriver($driver); + + $store->put('a', '/a', ['one']); + $store->put('b', '/b', ['two']); + + $store->flush(); + + $this->assertSame([], $store->all()); + $this->assertSame([], $store->matching(['one', 'two', '*'])); + + if ($driver === 'redis') { + $this->assertSame([], Redis::connection('cache_tracker_tests')->keys('*')); + } else { + $this->assertNull(Cache::store('static_cache')->get('tracker::tag:'.md5('one'))); + } + } + + #[Test] + #[DataProvider('drivers')] + public function it_migrates_the_legacy_single_key_format(string $driver) + { + $this->useDriver($driver); + + Cache::store('static_cache')->forever(Manager::LEGACY_CACHE_KEY, [ + md5('http://localhost/a') => ['url' => 'http://localhost/a', 'tags' => ['products:1']], + md5('http://localhost/b') => ['url' => 'http://localhost/b', 'tags' => ['products:2']], + ]); + + // a fresh manager migrates on first use + $this->app->forgetInstance(Manager::class); + Tracker::clearResolvedInstance(Manager::class); + + $this->assertCount(2, Tracker::all()); + $this->assertNotNull(Tracker::get('http://localhost/a')); + $this->assertFalse(Cache::store('static_cache')->has(Manager::LEGACY_CACHE_KEY)); + + Tracker::invalidate(['products:1']); + + $this->assertNull(Tracker::get('http://localhost/a')); + $this->assertNotNull(Tracker::get('http://localhost/b')); + } +} diff --git a/tests/Unit/TrackerTest.php b/tests/Unit/TrackerTest.php index 6940475..0ebd23c 100644 --- a/tests/Unit/TrackerTest.php +++ b/tests/Unit/TrackerTest.php @@ -55,7 +55,7 @@ public function it_doesnt_track_pages_already_in_the_manifest() { Event::fake(); - Tracker::add('/', ['some:thing']); + Tracker::add('http://localhost', ['some:thing']); Tracker::addAdditionalTracker(function ($tracker, $next) { $tracker->addContentTag('test::tag'); @@ -63,7 +63,8 @@ public function it_doesnt_track_pages_already_in_the_manifest() $this->get('/'); - $this->assertSame(['some:thing'], collect(Tracker::all())->first()['tags']); + $this->assertCount(1, Tracker::all()); + $this->assertSame(['some:thing'], Tracker::get('http://localhost')['tags']); } #[Test]