| name | koan-caching |
| description | Transparent L1/L2 caching for Entity<T>, [Cacheable] attribute, cross-node coherence, per-request opt-out |
Koan Caching
Trigger this skill when you see
[Cacheable] or [CachePolicy] attributes
EntityContext.NoCache() / RefreshCache() / WithCacheBehavior(...)
CacheKey.For<T>(...), Cache.WithJson<T>(...), Cache.Evict<T,K>(...)
- References to
Koan.Cache / Koan.Cache.Adapter.Redis / Koan.Cache.Adapter.Sqlite / Koan.Cache.Coherence.*
- HTTP
Cache-Control headers or X-Koan-Cache
- Performance discussions about repeated entity reads / cross-node staleness / cache stampedes
- "Cache invalidation", "L1/L2", "cache coherence", "write-through", "TTL", "tags"
Core principle
Reference = Intent. Add Koan.Cache to project references and [Cacheable] to an entity — that's the whole 90% case. The framework handles wiring, topology resolution, coherence, eviction, and instrumentation automatically.
[Cacheable(300)]
public sealed class Todo : Entity<Todo>
{
public string Title { get; set; } = "";
}
var todo = await Todo.Get(id);
var fast = await Todo.Get(id);
todo.Title = "updated";
await todo.Save();
Reference = Intent activation table
| Add this reference | Effect |
|---|
Koan.Cache | L1=Memory, single-node, no coherence |
+ Koan.Cache.Adapter.Sqlite | L1=SQLite (persistent across restart) |
+ Koan.Cache.Adapter.Redis | L2=Redis + coherence auto-activates |
+ Koan.Cache.Coherence.Messaging | Rides existing Koan.Messaging bus (preempts Redis pub/sub) |
+ Koan.Cache.Coherence.InMemory | In-process channel — tests / single-process verification |
Architecture: four pillars
| Pillar | Owns | Contract |
|---|
| Storage | K/V verbs | ICacheStore (Memory/SQLite/Redis) |
| Coherence | Cross-node invalidation | ICacheCoherenceChannel (Redis pub/sub, Koan.Messaging, in-memory) |
| Topology | L1/L2 wiring + write/evict orchestration | LayeredCache (internal) + CoherenceCoordinator (IHostedService) |
| Policy | Per-entity declarative intent | [Cacheable] / [CachePolicy] + CachePolicyMaterializer |
Critical invariant: ICacheStore has NO publish methods. Coherence is its own pillar with its own contract. LayeredCache.ApplyRemoteInvalidation touches L1 only — never L2 (shared, already evicted by writer), never republishes (would create feedback loops).
Anti-patterns to flag
| If you see | Suggest |
|---|
services.AddMemoryCache() + manual cache wrapper | [Cacheable] on the entity instead. |
IMemoryCache injected into a service for entity caching | Same — entity caching is policy-driven, not DI-imperative. |
Manual redis.GetDatabase().StringGetAsync(...) | Cache.WithJson<T>(key).GetOrAdd(...) for arbitrary values; [Cacheable] for entities. |
| Custom cache-invalidation pub/sub | [Cacheable] writes broadcast automatically. For non-entity cases, use ICacheCoherenceChannel directly. |
| String concatenation for cache keys | CacheKey.For<TEntity>(id, partition) — canonical, partition-aware. |
Cache-bypass via if (skipCache) ... branches in business code | EntityContext.NoCache() / RefreshCache() scopes — declarative, doesn't pollute call sites. |
| L1 TTL > L2 TTL | Boot-time validator throws. The materializer enforces the invariant. |
Stale-while-revalidate is opt-in (ARCH-0078)
Default reads are fresh-or-null — past AbsoluteTtl the cache returns null. To allow stale reads, callers must explicitly opt in via AllowStaleFor:
[Cacheable(60, AllowStaleForSeconds: 10)]
public sealed class HotKey : Entity<HotKey> { }
var report = await Cache.WithJson<UsageReport>(key)
.WithAbsoluteTtl(TimeSpan.FromMinutes(5))
.AllowStaleFor(TimeSpan.FromMinutes(2))
.GetOrAdd(async ct => ..., ct);
There are no global SWR toggles in adapter options or app configuration. The per-call/per-policy opt-in is the only switch. The boot report flags policies that opt in: Policy:HotKey ... swr=10s [opt-in].
Per-request opt-out
using (EntityContext.NoCache())
{
var fresh = await Todo.Get(id);
}
using (EntityContext.RefreshCache())
{
var rebuilt = await Todo.Get(id);
}
using (EntityContext.WithCacheBehavior(CacheBehavior.ReadOnly))
{
var cached = await Todo.Get(id);
}
Writes always invalidate regardless of the scope — peer L1 entries are evicted via coherence even when bypass mode suppresses the local populate. Prevents silent multi-node desync.
HTTP integration
app.UseKoanCacheControl() middleware (opt-in, in Koan.Web) maps standard HTTP cache headers onto EntityContext.CacheBehavior:
Cache-Control: no-cache → RefreshCache()
Cache-Control: no-store → NoCache()
X-Koan-Cache: refresh|bypass|readonly|default (wins over Cache-Control)
Out-of-band write evict
Koan.Data.Direct and batch jobs bypass the cache decorator. Use the canonical evict surface:
await Cache.Evict<Todo, string>(id, ct);
await EntityCacheExtensions.Cache<Todo, string>().FlushAll();
await todo.Uncache();
Power-user: non-entity caching
For computed/expensive values that aren't Entity<T>:
var report = await Cache.WithJson<UsageReport>($"report:{tenantId}")
.WithAbsoluteTtl(TimeSpan.FromHours(1))
.WithTags("reports", $"tenant:{tenantId}")
.GetOrAdd(async ct => await BuildReportAsync(tenantId, ct), ct);
await Cache.Tags($"tenant:{tenantId}").Flush(ct);
Production hardening checklist
See also