---
name: caching
description: In-memory caching patterns for the 3SC widget host. Covers cache strategies, invalidation, TTL policies, and when to cache vs when to fetch.
---
# Caching
## Overview
Caching improves performance by reducing database and network calls. This skill covers in-memory caching patterns suitable for desktop applications.
## Definition of Done (DoD)
- [ ] Frequently accessed data is cached appropriately
- [ ] Cache has defined TTL (time-to-live) for each entry type
- [ ] Cache invalidation is implemented for write operations
- [ ] Memory usage is bounded (max entries or max memory)
- [ ] Cache misses are logged for monitoring
- [ ] Thread-safety is ensured for concurrent access
## When to Cache
| Scenario | Cache? | TTL | Notes |
|----------|--------|-----|-------|
| Widget catalog (read-heavy) | ✅ Yes | 5 min | Invalidate on install/uninstall |
| Installed widgets list | ✅ Yes | 10 min | Invalidate on changes |
| Layout configurations | ✅ Yes | 5 min | Invalidate on save |
| User preferences | ✅ Yes | Session | Load once, cache for session |
| Active widget instances | ❌ No | - | Already in memory |
| Real-time data (sync queue) | ❌ No | - | Needs fresh data |
## Cache Service Implementation
### Interface
```csharp
public interface ICacheService
{
/// Gets cached value or default if not found/expired.
T? Get(string key) where T : class;
/// Gets cached value or executes factory to populate.
Task GetOrCreateAsync(
string key,
Func> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default) where T : class;
/// Sets value with optional expiration.
void Set(string key, T value, TimeSpan? expiration = null) where T : class;
/// Removes specific key.
void Remove(string key);
/// Removes all keys matching prefix.
void RemoveByPrefix(string prefix);
/// Clears entire cache.
void Clear();
/// Gets cache statistics.
CacheStatistics GetStatistics();
}
public record CacheStatistics(int EntryCount, long Hits, long Misses, long Evictions);
```
### Implementation
```csharp
public class MemoryCacheService : ICacheService, IDisposable
{
private readonly ConcurrentDictionary _cache = new();
private readonly Timer _cleanupTimer;
private readonly int _maxEntries;
private long _hits;
private long _misses;
private long _evictions;
public MemoryCacheService(int maxEntries = 1000, TimeSpan? cleanupInterval = null)
{
_maxEntries = maxEntries;
_cleanupTimer = new Timer(
CleanupExpired,
null,
cleanupInterval ?? TimeSpan.FromMinutes(1),
cleanupInterval ?? TimeSpan.FromMinutes(1));
}
public T? Get(string key) where T : class
{
if (_cache.TryGetValue(key, out var entry) && !entry.IsExpired)
{
Interlocked.Increment(ref _hits);
entry.Touch();
return (T)entry.Value;
}
Interlocked.Increment(ref _misses);
if (entry?.IsExpired == true)
{
_cache.TryRemove(key, out _);
}
return null;
}
public async Task GetOrCreateAsync(
string key,
Func> factory,
TimeSpan? expiration = null,
CancellationToken cancellationToken = default) where T : class
{
var existing = Get(key);
if (existing != null)
return existing;
// Use lock to prevent duplicate factory calls
var value = await factory(cancellationToken).ConfigureAwait(false);
Set(key, value, expiration);
return value;
}
public void Set(string key, T value, TimeSpan? expiration = null) where T : class
{
EnsureCapacity();
var entry = new CacheEntry(value, expiration);
_cache[key] = entry;
Log.Debug("Cache set: {Key}, Expires: {Expiration}",
key, entry.ExpiresAt?.ToString("HH:mm:ss") ?? "never");
}
public void Remove(string key)
{
if (_cache.TryRemove(key, out _))
{
Log.Debug("Cache removed: {Key}", key);
}
}
public void RemoveByPrefix(string prefix)
{
var keysToRemove = _cache.Keys
.Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.ToList();
foreach (var key in keysToRemove)
{
_cache.TryRemove(key, out _);
}
Log.Debug("Cache cleared {Count} entries with prefix: {Prefix}",
keysToRemove.Count, prefix);
}
public void Clear()
{
var count = _cache.Count;
_cache.Clear();
Log.Information("Cache cleared: {Count} entries removed", count);
}
public CacheStatistics GetStatistics() =>
new(_cache.Count, _hits, _misses, _evictions);
private void EnsureCapacity()
{
if (_cache.Count < _maxEntries)
return;
// Evict oldest entries (LRU)
var toEvict = _cache
.OrderBy(x => x.Value.LastAccessed)
.Take(_cache.Count / 4) // Evict 25%
.Select(x => x.Key)
.ToList();
foreach (var key in toEvict)
{
if (_cache.TryRemove(key, out _))
{
Interlocked.Increment(ref _evictions);
}
}
Log.Debug("Cache evicted {Count} entries", toEvict.Count);
}
private void CleanupExpired(object? state)
{
var expired = _cache
.Where(x => x.Value.IsExpired)
.Select(x => x.Key)
.ToList();
foreach (var key in expired)
{
_cache.TryRemove(key, out _);
}
if (expired.Count > 0)
{
Log.Debug("Cache cleanup: {Count} expired entries removed", expired.Count);
}
}
public void Dispose()
{
_cleanupTimer.Dispose();
}
private class CacheEntry
{
public object Value { get; }
public DateTimeOffset? ExpiresAt { get; }
public DateTimeOffset LastAccessed { get; private set; }
public bool IsExpired => ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt;
public CacheEntry(object value, TimeSpan? expiration)
{
Value = value;
LastAccessed = DateTimeOffset.UtcNow;
ExpiresAt = expiration.HasValue
? DateTimeOffset.UtcNow.Add(expiration.Value)
: null;
}
public void Touch() => LastAccessed = DateTimeOffset.UtcNow;
}
}
```
## Cache Keys Convention
Use hierarchical keys for easy invalidation:
```csharp
public static class CacheKeys
{
// Pattern: {entity}:{scope}:{identifier}
public const string WidgetCatalog = "widgets:catalog:all";
public const string InstalledWidgets = "widgets:installed:all";
public static string Widget(string widgetKey) => $"widgets:detail:{widgetKey}";
public static string Layout(Guid layoutId) => $"layouts:detail:{layoutId}";
public static string UserSettings(string key) => $"settings:user:{key}";
// Prefixes for bulk invalidation
public const string WidgetsPrefix = "widgets:";
public const string LayoutsPrefix = "layouts:";
}
```
## Repository with Caching
```csharp
public class CachedWidgetRepository : IWidgetRepository
{
private readonly IWidgetRepository _inner;
private readonly ICacheService _cache;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public CachedWidgetRepository(IWidgetRepository inner, ICacheService cache)
{
_inner = inner;
_cache = cache;
}
public async Task> GetAllAsync(CancellationToken ct = default)
{
return await _cache.GetOrCreateAsync(
CacheKeys.WidgetCatalog,
async token => (await _inner.GetAllAsync(token)).ToList(),
CacheDuration,
ct);
}
public async Task GetByKeyAsync(string widgetKey, CancellationToken ct = default)
{
return await _cache.GetOrCreateAsync(
CacheKeys.Widget(widgetKey),
token => _inner.GetByKeyAsync(widgetKey, token),
CacheDuration,
ct);
}
public async Task AddAsync(Widget widget, CancellationToken ct = default)
{
await _inner.AddAsync(widget, ct);
// Invalidate related caches
_cache.Remove(CacheKeys.WidgetCatalog);
_cache.Remove(CacheKeys.Widget(widget.WidgetKey));
}
public async Task UpdateAsync(Widget widget, CancellationToken ct = default)
{
await _inner.UpdateAsync(widget, ct);
_cache.Remove(CacheKeys.Widget(widget.WidgetKey));
_cache.Remove(CacheKeys.WidgetCatalog);
}
public async Task DeleteAsync(string widgetKey, CancellationToken ct = default)
{
await _inner.DeleteAsync(widgetKey, ct);
_cache.RemoveByPrefix(CacheKeys.WidgetsPrefix);
}
}
```
## Cache-Aside Pattern
For more control over cache population:
```csharp
public async Task GetWidgetAsync(string widgetKey, CancellationToken ct)
{
// 1. Check cache
var cached = _cache.Get(CacheKeys.Widget(widgetKey));
if (cached != null)
return cached;
// 2. Load from database
var widget = await _repository.GetByKeyAsync(widgetKey, ct);
// 3. Populate cache (even if null, to prevent repeated lookups)
if (widget != null)
{
_cache.Set(CacheKeys.Widget(widgetKey), widget, TimeSpan.FromMinutes(5));
}
return widget;
}
```
## Cache Warming
Pre-populate cache at startup:
```csharp
public class CacheWarmupService
{
private readonly ICacheService _cache;
private readonly IWidgetRepository _widgetRepo;
private readonly ILayoutRepository _layoutRepo;
public async Task WarmupAsync(CancellationToken ct)
{
Log.Information("Starting cache warmup");
var tasks = new[]
{
WarmWidgetsAsync(ct),
WarmLayoutsAsync(ct)
};
await Task.WhenAll(tasks);
var stats = _cache.GetStatistics();
Log.Information("Cache warmup complete: {Count} entries", stats.EntryCount);
}
private async Task WarmWidgetsAsync(CancellationToken ct)
{
var widgets = await _widgetRepo.GetAllAsync(ct);
_cache.Set(CacheKeys.WidgetCatalog, widgets.ToList(), TimeSpan.FromMinutes(10));
foreach (var widget in widgets)
{
_cache.Set(CacheKeys.Widget(widget.WidgetKey), widget, TimeSpan.FromMinutes(10));
}
}
private async Task WarmLayoutsAsync(CancellationToken ct)
{
// Similar pattern...
}
}
```
## Best Practices
| Practice | Reason |
|----------|--------|
| Always set TTL | Prevents stale data and memory leaks |
| Invalidate on writes | Ensures cache consistency |
| Use hierarchical keys | Enables prefix-based invalidation |
| Bound cache size | Prevents unbounded memory growth |
| Log cache metrics | Helps tune cache effectiveness |
| Don't cache nulls (usually) | Unless preventing repeated lookups |
## Anti-Patterns
| Anti-Pattern | Problem | Solution |
|--------------|---------|----------|
| Unbounded cache | Memory leak | Set max entries |
| No TTL | Stale data forever | Always set expiration |
| Cache complex graphs | Inconsistent updates | Cache simple DTOs |
| Distributed cache for desktop | Over-engineering | Use simple in-memory |
| Caching mutable objects | Race conditions | Cache immutable/copies |
## Monitoring
```csharp
// Log cache effectiveness periodically
public void LogCacheMetrics()
{
var stats = _cache.GetStatistics();
var hitRate = stats.Hits + stats.Misses > 0
? (double)stats.Hits / (stats.Hits + stats.Misses) * 100
: 0;
Log.Information(
"Cache metrics - Entries: {Entries}, HitRate: {HitRate:F1}%, " +
"Hits: {Hits}, Misses: {Misses}, Evictions: {Evictions}",
stats.EntryCount, hitRate, stats.Hits, stats.Misses, stats.Evictions);
}
```
## References
- [Caching Best Practices](https://docs.microsoft.com/en-us/dotnet/core/extensions/caching)
- [Cache-Aside Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside)