From 1ffb8f00703b47ecc2e69e0ea33adc87951c17ed Mon Sep 17 00:00:00 2001 From: AsmitNepali Date: Tue, 28 Apr 2026 22:11:24 +0545 Subject: [PATCH] fix(cache): scope TimelineCache::forget() to single subject Replace store-wide flush() with per-subject key index. TimelineCache now tracks cached keys in `{prefix}:{class}:{id}:index` and forget() deletes only those keys plus the index entry, leaving sessions, queue locks, and other application caches in the same store untouched. Also updates docs to remove the "known limitation" callouts. Fixes #12 --- docs/content/3.essentials/5.caching.md | 19 ++---- docs/content/3.essentials/6.configuration.md | 2 +- docs/content/6.troubleshooting/1.index.md | 12 +--- src/Timeline/TimelineBuilder.php | 3 +- src/Timeline/TimelineCache.php | 67 +++++++++++++++++--- tests/Feature/TimelineCacheTest.php | 45 +++++++++++++ 6 files changed, 113 insertions(+), 35 deletions(-) diff --git a/docs/content/3.essentials/5.caching.md b/docs/content/3.essentials/5.caching.md index b8ed4d6..5b74f39 100644 --- a/docs/content/3.essentials/5.caching.md +++ b/docs/content/3.essentials/5.caching.md @@ -4,7 +4,7 @@ description: Opt-in per-call caching, key composition, and invalidation caveats. navigation: icon: i-lucide-database seo: - description: Per-call caching, cache key composition, and the forgetTimelineCache full-flush limitation in relaticle/activity-log. + description: Per-call caching, cache key composition, and per-subject invalidation in relaticle/activity-log. ogImage: /preview.png --- @@ -48,20 +48,11 @@ Where: Changing any filter — `->ofType(...)`, `->between(...)`, `->sortByDateAsc()` — produces a different key, so re-running the same builder with different chain state does **not** collide on a stale entry. -## Invalidation — known limitation +## Invalidation -::callout{icon="i-lucide-alert-triangle" color="warning"} -**`$record->forgetTimelineCache()` flushes the entire cache store, not just this subject's timeline entries.** +`$record->forgetTimelineCache()` invalidates only this subject's cached timeline pages. It tracks the keys it writes in a per-subject index entry (`{prefix}:{model_class}:{key}:index`) and forgets exactly those keys plus the index — sessions, queue locks, and other application caches in the same store are untouched. -Internally it calls `Cache::store(...)->getStore()->flush()`. If you share the default cache store with sessions, application caches, queue locks, or anything else, calling `forgetTimelineCache()` clears all of them. - -Tracked by [issue #12](https://github.com/relaticle/activity-log/issues/12). The recommended fix is tagged-cache invalidation keyed on the per-subject prefix. - -**Workarounds until the fix lands:** - -- **Use a dedicated cache store.** Set `cache.store` to a Redis database, file path, or memory store dedicated to the timeline. Flushing it then only affects timeline entries — see [Configuration knobs](#configuration-knobs) below. -- **Skip explicit invalidation.** Pick a TTL short enough that staleness is acceptable (e.g. 60 seconds for a high-traffic dashboard) and let entries expire naturally. No `forgetTimelineCache()` call needed. -:: +Alternative: skip explicit invalidation and pick a TTL short enough that staleness is acceptable (e.g. 60 seconds for a high-traffic dashboard) and let entries expire naturally. ## Configuration knobs @@ -69,6 +60,6 @@ Short reference here; the full table lives on [/essentials/configuration#cache]( | Key | Default | Effect | |---|---|---| -| `cache.store` | `null` (default cache) | Which Laravel cache store to use. **Strongly recommended: a dedicated store** (see invalidation caveat above). | +| `cache.store` | `null` (default cache) | Which Laravel cache store to use. | | `cache.ttl_seconds` | `0` | Reserved; not currently consulted by `TimelineCache`. The per-call `->cached($ttl)` is the working knob. | | `cache.key_prefix` | `'activity-log'` | Namespace for cache keys. | diff --git a/docs/content/3.essentials/6.configuration.md b/docs/content/3.essentials/6.configuration.md index 4a675a1..00af527 100644 --- a/docs/content/3.essentials/6.configuration.md +++ b/docs/content/3.essentials/6.configuration.md @@ -22,7 +22,7 @@ Every knob the package exposes lives in `config/activity-log.php`. Publish it wi | `source_priorities.related_model` | `int` | `20` | Priority for `RelatedModelSource`. | | `source_priorities.custom` | `int` | `30` | Priority for `CustomEventSource`. | | `renderers` | `array` | `[]` | Event-or-type → renderer map. See [/essentials/customization#registration-channels](/essentials/customization#registration-channels). | -| `cache.store` | `?string` | `null` (default cache) | Laravel cache store name. **Recommended: dedicated store** to avoid `forgetTimelineCache` cross-contamination. See [/essentials/caching](/essentials/caching). | +| `cache.store` | `?string` | `null` (default cache) | Laravel cache store name. See [/essentials/caching](/essentials/caching). | | `cache.ttl_seconds` | `int` | `0` | Reserved; not currently consulted by `TimelineCache`. The per-call `->cached($ttl)` is the working knob. | | `cache.key_prefix` | `string` | `'activity-log'` | Prefix for all cache keys. | diff --git a/docs/content/6.troubleshooting/1.index.md b/docs/content/6.troubleshooting/1.index.md index a02d4f7..e4972b6 100644 --- a/docs/content/6.troubleshooting/1.index.md +++ b/docs/content/6.troubleshooting/1.index.md @@ -4,7 +4,7 @@ description: Common pitfalls, known limitations, and how to work around them. navigation: icon: i-lucide-life-buoy seo: - description: FAQ and known limitations for relaticle/activity-log — Tailwind, renderers, dedup, type filters, cache invalidation, unsaved subjects. + description: FAQ and known limitations for relaticle/activity-log — Tailwind, renderers, dedup, type filters, unsaved subjects. ogImage: /preview.png --- @@ -31,16 +31,6 @@ Dedup uses `dedupKey` + `sourcePriority`. If two sources emit the same logical e - **Tracking:** [issue #11](https://github.com/relaticle/activity-log/issues/11). :: -## Cache invalidation flushed unrelated caches - -::callout{icon="i-lucide-alert-triangle" color="warning"} -- **Symptom:** after calling `$record->forgetTimelineCache()`, sessions / queue locks / application caches are gone too. -- **Cause:** `TimelineCache::forget()` calls `Cache::store(...)->getStore()->flush()` — flushes the entire cache store, not just this subject's keys. -- **Workaround:** configure `cache.store` to a dedicated Laravel cache store used only by the timeline. Or skip explicit invalidation and let TTL expire naturally (set a short `->cached($ttl)`). -- **Tracking:** [issue #12](https://github.com/relaticle/activity-log/issues/12). -- Full caveat at [/essentials/caching#invalidation--known-limitation](/essentials/caching#invalidation--known-limitation). -:: - ## `fromActivityLog()` throws on a fresh model ::callout{icon="i-lucide-alert-triangle" color="warning"} diff --git a/src/Timeline/TimelineBuilder.php b/src/Timeline/TimelineBuilder.php index a64f006..392f8d5 100644 --- a/src/Timeline/TimelineBuilder.php +++ b/src/Timeline/TimelineBuilder.php @@ -280,7 +280,8 @@ public function paginate(?int $perPage = null, int $page = 1): LengthAwarePagina $cache = resolve(TimelineCache::class); $key = $cache->keyFor($this->subject, $this->filterHash(), $page, $perPage); - return $cache->store()->remember( + return $cache->remember( + $this->subject, $key, $this->cacheTtl, fn (): LengthAwarePaginator => $this->runPaginate($perPage, $page), diff --git a/src/Timeline/TimelineCache.php b/src/Timeline/TimelineCache.php index b353949..551374d 100644 --- a/src/Timeline/TimelineCache.php +++ b/src/Timeline/TimelineCache.php @@ -4,6 +4,7 @@ namespace Relaticle\ActivityLog\Timeline; +use Closure; use Illuminate\Contracts\Cache\Repository; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; @@ -19,23 +20,73 @@ public function store(): Repository public function keyFor(Model $subject, string $filterHash, int $page, int $perPage): string { - $prefix = (string) config('activity-log.cache.key_prefix', 'activity-log'); - return sprintf( - '%s:%s:%s:%s:p%d:pp%d', - $prefix, - str_replace('\\', '_', $subject::class), - (string) $subject->getKey(), + '%s:%s:p%d:pp%d', + $this->subjectPrefix($subject), $filterHash, $page, $perPage, ); } + /** + * @template TValue + * + * @param Closure(): TValue $callback + * @return TValue + */ + public function remember(Model $subject, string $key, int $ttl, Closure $callback): mixed + { + $this->trackKey($subject, $key); + + return $this->store()->remember($key, $ttl, $callback); + } + public function forget(Model $subject): void { - unset($subject); + $store = $this->store(); + $indexKey = $this->indexKey($subject); + + /** @var array $keys */ + $keys = $store->get($indexKey, []); + + foreach ($keys as $key) { + $store->forget($key); + } + + $store->forget($indexKey); + } + + private function trackKey(Model $subject, string $key): void + { + $store = $this->store(); + $indexKey = $this->indexKey($subject); - $this->store()->getStore()->flush(); + /** @var array $keys */ + $keys = $store->get($indexKey, []); + + if (in_array($key, $keys, true)) { + return; + } + + $keys[] = $key; + $store->forever($indexKey, $keys); + } + + private function indexKey(Model $subject): string + { + return $this->subjectPrefix($subject).':index'; + } + + private function subjectPrefix(Model $subject): string + { + $prefix = (string) config('activity-log.cache.key_prefix', 'activity-log'); + + return sprintf( + '%s:%s:%s', + $prefix, + str_replace('\\', '_', $subject::class), + (string) $subject->getKey(), + ); } } diff --git a/tests/Feature/TimelineCacheTest.php b/tests/Feature/TimelineCacheTest.php index 7a080be..ca7df49 100644 --- a/tests/Feature/TimelineCacheTest.php +++ b/tests/Feature/TimelineCacheTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Cache; use Relaticle\ActivityLog\Tests\Fixtures\Models\Email; use Relaticle\ActivityLog\Tests\Fixtures\Models\Person; use Relaticle\ActivityLog\Timeline\Sources\RelatedModelSource; @@ -49,3 +50,47 @@ expect($after->total())->toBe(2); }); + +it('forgetTimelineCache() leaves unrelated cache entries intact', function (): void { + $person = Person::factory()->create(); + Email::factory()->for($person)->create(['sent_at' => CarbonImmutable::now()]); + + TimelineBuilder::make($person) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + Cache::put('unrelated:session:abc', 'keep-me', 300); + + $person->forgetTimelineCache(); + + expect(Cache::get('unrelated:session:abc'))->toBe('keep-me'); +}); + +it('forgetTimelineCache() does not affect other subjects', function (): void { + $alice = Person::factory()->create(); + $bob = Person::factory()->create(); + Email::factory()->for($alice)->create(['sent_at' => CarbonImmutable::now()]); + Email::factory()->for($bob)->create(['sent_at' => CarbonImmutable::now()]); + + TimelineBuilder::make($alice) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + $bobFirst = TimelineBuilder::make($bob) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + Email::factory()->for($bob)->create(['sent_at' => CarbonImmutable::now()]); + + $alice->forgetTimelineCache(); + + $bobAfter = TimelineBuilder::make($bob) + ->fromRelation('emails', fn (RelatedModelSource $s): RelatedModelSource => $s->event('sent_at', 'email_sent')) + ->cached(ttlSeconds: 60) + ->paginate(perPage: 5); + + expect($bobAfter->total())->toBe($bobFirst->total()); +});