Skip to content
Draft
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
19 changes: 5 additions & 14 deletions docs/content/3.essentials/5.caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down Expand Up @@ -48,27 +48,18 @@ 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

Short reference here; the full table lives on [/essentials/configuration#cache](/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. |
2 changes: 1 addition & 1 deletion docs/content/3.essentials/6.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
12 changes: 1 addition & 11 deletions docs/content/6.troubleshooting/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand All @@ -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"}
Expand Down
3 changes: 2 additions & 1 deletion src/Timeline/TimelineBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
67 changes: 59 additions & 8 deletions src/Timeline/TimelineCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int, string> $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<int, string> $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(),
);
}
}
45 changes: 45 additions & 0 deletions tests/Feature/TimelineCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
});
Loading