Skip to content

Commit 253a265

Browse files
authored
Merge pull request #1000 from SonicJs-Org/lane711/cache-plugin-status
fix(cache): repair api-namespace list cache invalidation
2 parents 849da12 + 15a037e commit 253a265

7 files changed

Lines changed: 1187 additions & 2294 deletions

File tree

.github/workflows/pr-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ jobs:
4545
cache: 'npm'
4646

4747
- name: Install dependencies
48-
run: npm ci
48+
run: npm install
4949

5050
- name: Type check
5151
run: npm run type-check
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Cache Invalidation — Findings & Recommendations
2+
3+
**Date:** 2026-07-06
4+
**Scope:** How the cache plugin invalidates entries today, how mainstream cache systems solve list-vs-item invalidation, and what SonicJS should do next.
5+
6+
---
7+
8+
## 1. How SonicJS handles it today
9+
10+
### 1.1 Two invalidation mechanisms coexist
11+
12+
**A. Event-bus listeners** (`packages/core/src/plugins/cache/services/cache-invalidation.ts`)
13+
14+
`setupCacheInvalidation()` runs at plugin activation and registers listeners on an in-memory event bus:
15+
16+
| Event | Action |
17+
|---|---|
18+
| `content.create` / `content.publish` | wipe `content:*` + wipe `api:*` |
19+
| `content.update` | delete `content:item:<id>`, wipe `content:list:*`, wipe `api:*` |
20+
| `content.delete` | delete item key, wipe `content:*`, wipe `api:*` |
21+
| `media.upload/update/delete` | delete item key, wipe `media:list:*` (or `media:*`) |
22+
| `user.update/delete`, `auth.login/logout` | delete per-user / per-session keys |
23+
| `config.update`, `plugin.*`, `collection.*` | wipe `config:*` / `plugin:*` / `collection:*` |
24+
25+
**Important:** only `api-media.ts` actually emits events (`media.upload`, `media.delete`, `media.update`, `media.move`). **No route emits `content.*`, `user.*`, `config.*`, `plugin.*`, or `collection.*` events.** Those listeners are dead wiring today — well-tested dead wiring (the unit tests emit the events manually), but nothing in production code fires them.
26+
27+
**B. Inline invalidation in route handlers** (the mechanism that actually runs for content)
28+
29+
Write handlers call `cache.invalidate(pattern)` directly after mutating:
30+
31+
- `admin-content.ts` (create/update/delete/bulk):
32+
- `cache.delete('content:content:<id>:v1')` — exact item key
33+
- `cache.invalidate('content:list:<collectionId>:*')` — per-collection lists (some paths)
34+
- `cache.invalidate('content:list:*')` — all lists (other paths)
35+
- `apiCache.invalidate('content-filtered:*')` + `apiCache.invalidate('collection-content-filtered:*')`**all** public-API list caches
36+
- `api.ts` and `api-content-crud.ts` (public CRUD): same two `api`-namespace wipes after every write.
37+
38+
### 1.2 So: how does a list cache "know" an item changed?
39+
40+
It doesn't. The strategy is **coarse pattern-based invalidation**: any write to any content item nukes **every** cached list response in the `api` namespace, across **all** collections. There is no dependency tracking between list caches and their member items.
41+
42+
Granularity today:
43+
44+
| Cache | Key shape | Invalidation on item write |
45+
|---|---|---|
46+
| Item | `content:content:<id>:v1` | exact-key delete ✅ |
47+
| Admin lists | `content:list:<collectionId>:page:N` | per-collection wipe (some paths), global wipe (others) |
48+
| API list (cross-collection) | `api:content-filtered:<json-of-filter+sql>:v1` | global wipe |
49+
| API list (per-collection) | `api:collection-content-filtered:<collection>:<json>:v1` | **global wipe — even though the key already embeds the collection name** |
50+
51+
### 1.3 Safety nets & costs
52+
53+
- **TTL backstop**: `api` namespace = 300 s, `content` = 3600 s. Even a missed invalidation self-heals within TTL.
54+
- **KV pattern invalidation is expensive**: KV has no pattern delete, so `invalidate()` does `list({prefix})` + regex test + one `delete()` per match (`cache.ts:359-392`). Sequential awaits, and KV list is eventually consistent — a just-written entry can survive the sweep.
55+
- **Two-axis coverage gap**: memory tier is per-isolate. An invalidation runs in the isolate that handled the write; other isolates' memory tiers keep stale entries until TTL. KV tier is shared, so cross-isolate correctness leans on KV + short TTL.
56+
57+
### 1.4 Document-model gap
58+
59+
`DocumentsService` / `api-documents.ts` / `admin-documents.ts` have **zero cache integration** — no caching, no invalidation, no events. Legacy `admin-content.ts` handles its own invalidation inline, but any future read path that caches document queries has no invalidation hook to attach to. Worth deciding before the media read-flip completes.
60+
61+
---
62+
63+
## 2. How mainstream cache systems handle list invalidation
64+
65+
Ordered roughly from crude to sophisticated:
66+
67+
### 2.1 TTL-only ("eventual freshness")
68+
Do nothing on write; let entries expire. Simple, zero write-path cost, bounded staleness. Fine for content where N-seconds staleness is acceptable. Most CDN caching defaults to this.
69+
70+
### 2.2 Purge-by-key
71+
Writer deletes exact keys it knows about. Works for item caches; **fails for lists** because the writer can't enumerate every filter/sort/pagination combination that might contain the item. This is why SonicJS pairs it with pattern wipes.
72+
73+
### 2.3 Tag-based (a.k.a. surrogate keys) — the industry answer to your question
74+
Each cache entry is stored with a set of **tags** describing what it depends on. Invalidation targets a tag, and the store drops every entry carrying it.
75+
76+
- Cloudflare `Cache-Tag` / Fastly `Surrogate-Key` headers, purge-by-tag APIs
77+
- Laravel `Cache::tags(['posts'])->flush()`, Spring `@CacheEvict`, Next.js `revalidateTag()`
78+
- A list response for collection `blog` gets tagged `collection:blog`; item `123` gets `content:123` + `collection:blog`
79+
- Update/insert/delete of any blog item → purge tag `collection:blog` → every list containing blog content dies, nothing else does
80+
81+
This is exactly the "track at collection level" idea in the question — it's the standard, and the right granularity trade-off.
82+
83+
### 2.4 Per-item dependency tracking ("track items in a cache list")
84+
Store, per cached list, the exact set of item IDs it contains; on item write, invalidate only lists containing that ID. Answering the question directly: **almost nobody does this, and you shouldn't**:
85+
86+
- **Inserts break it**: a brand-new item belongs to no existing list's ID-set, yet must invalidate every list whose filter it matches (e.g. "10 newest posts"). You'd have to evaluate every cached query's predicate against the new row — that's a query-subscription engine, not a cache.
87+
- Deletes and updates-that-change-filter-membership have the same problem in reverse (an update can move an item *into* a list it wasn't in).
88+
- Bookkeeping cost is O(items × lists) and must itself be stored/invalidated consistently.
89+
90+
Systems that genuinely need this precision (materialized views, Noria, Readyset, Meta's TAO) are database-layer incremental-view-maintenance engines. For an application cache, collection-level tags capture ~all of the benefit at ~none of the cost.
91+
92+
### 2.5 Versioned keys / generation counters (key-based expiry)
93+
Instead of finding-and-deleting entries, embed a **generation number** in the key:
94+
95+
```
96+
api:v{gen}:collection-content-filtered:blog:<query-hash>
97+
```
98+
99+
On any write to collection `blog`, bump `gen(blog)` (one counter write). All old entries become unreachable instantly and age out via TTL — **O(1) invalidation, no enumeration**. Memcached's classic "namespace versioning" pattern; Rails calls it key-based cache expiration. Trade-off: orphaned entries occupy space until TTL evicts them.
100+
101+
### 2.6 Event-driven invalidation
102+
Writes emit events; subscribers invalidate. What SonicJS's event bus was built for — decouples writers from cache layout. Caveat in Workers: an in-memory bus only reaches the isolate that handled the write (fine here because the shared KV tier is what matters cross-isolate).
103+
104+
---
105+
106+
## 3. Answers to the specific questions
107+
108+
**"How does it know to invalidate the cache?"**
109+
It doesn't know — it over-invalidates. Every content write wipes all API list caches via `invalidate('content-filtered:*')` + `invalidate('collection-content-filtered:*')`, plus deletes the exact item key. Event listeners exist for content but nothing emits those events; inline route calls do the real work.
110+
111+
**"Do we need to track items in a cache list?"**
112+
No. Per-item dependency tracking can't handle inserts (a new item is in no tracked list but must invalidate matching lists), so you end up needing collection-level invalidation anyway. Skip straight to it.
113+
114+
**"What about inserts and deletes?"**
115+
They're the argument *for* collection-level granularity: any insert/delete/update within collection C can affect any list of C (ordering, counts, pagination, filter membership) — and cannot affect lists of other collections (cross-collection queries aside). So "write to C → invalidate everything tagged C" is both sound and minimal at reasonable cost.
116+
117+
**"Track at collection level, invalidate all caches that include that collection?"**
118+
Yes — that is tag-based invalidation, and it's the recommended design. See below.
119+
120+
---
121+
122+
## 4. Recommendation for SonicJS
123+
124+
### 4.1 Adopt collection-scoped invalidation (cheap first step)
125+
`collection-content-filtered` keys **already embed the collection name** (`api.ts:871`). Change write paths from:
126+
127+
```ts
128+
await cache.invalidate('collection-content-filtered:*') // all collections
129+
```
130+
to:
131+
```ts
132+
await cache.invalidate(`collection-content-filtered:${collection}:*`) // one collection
133+
```
134+
135+
Cross-collection queries (`content-filtered:*`) keep the global wipe (or get tagged with every collection they touch, later). Low-risk, no schema change, immediate reduction in cache churn.
136+
137+
### 4.2 Prefer generation counters over KV enumeration (right long-term shape)
138+
KV list-and-delete is slow, sequential, and eventually consistent. Replace pattern-delete in the `api` namespace with a **per-collection generation**:
139+
140+
- Keep `gen(collection)` in memory + KV (`cache:gen:<collection>`)
141+
- Cache keys include the generation: `api:collection-content-filtered:blog:g42:<hash>`
142+
- On write: bump the counter (single KV `put`) — O(1), atomic-enough, no enumeration
143+
- Stale entries expire via the existing 300 s TTL
144+
145+
This also fixes the cross-isolate staleness hole for the KV tier: a generation bump is visible to all isolates on their next read (memory tier still relies on TTL, as today).
146+
147+
### 4.3 Unify on the event bus (consistency)
148+
Pick one mechanism. Either delete the dead `content.*`/`collection.*` listeners, or (better) make routes/services emit events and remove inline `invalidate()` calls. The event log already powers `/admin/cache/analytics` invalidation stats — inline calls are invisible there today, so the analytics under-report.
149+
150+
### 4.4 Wire the document model in (gap)
151+
`DocumentsService.create/saveDraft/publish/unpublish/erase` should emit `content.*`-equivalent events (or call a single `invalidateCollection(collection)` helper) so that when document reads get cached — or when legacy routes flip to the document path — invalidation doesn't silently disappear. Collection name is available at every DocumentsService call site.
152+
153+
### 4.5 Non-goals
154+
- Per-item → list dependency tracking (see §2.4 — insert problem, bookkeeping cost)
155+
- Query-predicate matching / incremental view maintenance — database-layer tech, wrong altitude for this cache
156+
- Shortening TTLs as a substitute for correct invalidation — the 300 s `api` TTL is a good backstop; keep it as backstop, not primary mechanism
157+
158+
---
159+
160+
## 5. File reference
161+
162+
| Concern | Location |
163+
|---|---|
164+
| Event listeners (invalidation rules) | `packages/core/src/plugins/cache/services/cache-invalidation.ts` |
165+
| Pattern invalidation impl (memory + KV sweep) | `packages/core/src/plugins/cache/services/cache.ts:359` |
166+
| Key format / namespaces / TTLs | `packages/core/src/plugins/cache/services/cache-config.ts` |
167+
| Event bus | `packages/core/src/plugins/cache/services/event-bus.ts` |
168+
| Inline invalidation — admin writes | `packages/core/src/routes/admin-content.ts:1264,1303,1414,1548,1926,1984` |
169+
| Inline invalidation — public API writes | `packages/core/src/routes/api.ts:1117,1171,1211`, `api-content-crud.ts:246,328,384` |
170+
| Only event emitter in production code | `packages/core/src/routes/api-media.ts` |
171+
| List cache key with collection embedded | `packages/core/src/routes/api.ts:871` |

0 commit comments

Comments
 (0)