Caching
This guide covers caching patterns in Wallow using Valkey (a Redis-compatible key-value store).
Overview
Wallow uses Valkey as its distributed caching and real-time infrastructure layer.
| Use Case | Description |
|---|---|
| Distributed Cache | IDistributedCache for cross-instance data sharing |
| SignalR Backplane | WebSocket message distribution across server instances |
| Presence Tracking | Real-time user online status and page context |
| API Key Storage | Service account authentication tokens |
Configuration
Connection String
Configure the Valkey connection in appsettings.json:
{
"ConnectionStrings": {
"Redis": "localhost:6379,abortConnect=false"
}
}
Common connection options:
| Option | Description | Example |
|---|---|---|
abortConnect |
Don't fail on startup if unavailable | false |
connectTimeout |
Connection timeout in ms | 5000 |
syncTimeout |
Sync operation timeout in ms | 5000 |
password |
Authentication password | your-password |
ssl |
Enable TLS | true |
allowAdmin |
Enable admin commands (tests only) | true |
Docker Container (Local Development)
The docker-compose.yml includes a Valkey container with append-only persistence, authentication, and LRU eviction:
valkey:
image: valkey/valkey:8-alpine
container_name: ${COMPOSE_PROJECT_NAME:-wallow}-valkey
command: valkey-server --appendonly yes --requirepass ${VALKEY_PASSWORD} --maxmemory ${VALKEY_MAXMEMORY:-256mb} --maxmemory-policy allkeys-lru
ports:
- "127.0.0.1:6379:6379"
volumes:
- valkey_data:/data
environment:
REDISCLI_AUTH: ${VALKEY_PASSWORD}
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
Start the infrastructure:
pnpm backend:infra
That wraps the Compose invocation from the repository root; pnpm backend:infra:down stops it again.
Registration
In Program.cs, the IConnectionMultiplexer is registered as a singleton with deferred connection. The distributed cache (IDistributedCache) is then layered on top, wrapped with InstrumentedDistributedCache (api/src/Shared/Wallow.Shared.Infrastructure.Core/Cache/InstrumentedDistributedCache.cs) for cache hit/miss metrics.
Distributed Caching
IDistributedCache
For standard caching scenarios, inject IDistributedCache. Wallow registers it via AddStackExchangeRedisCache, reusing the singleton IConnectionMultiplexer.
Cache Key Patterns
| Pattern | Example | Use Case |
|---|---|---|
{domain}:{identifier} |
apikey:{hash} |
Simple lookups |
{domain}:{tenant}:{identifier} |
presence:{tenantId}:conn2user |
Tenant-scoped data |
{domain}:{scope}:{key} |
presence:{tenantId}:user:user-id-123 |
Hierarchical data |
TTL Strategies
| Strategy | Use Case | Example |
|---|---|---|
| Absolute Expiration | Data that becomes stale | Feature flags (5 min) |
| Sliding Expiration | Session-like data | User preferences (30 min) |
| No Expiration + Manual Invalidation | Rarely changing data | Configuration |
| Safety-Net Expiration | Data cleaned up on an event, with TTL as backstop | Presence keys (30 min) |
Use DistributedCacheEntryOptions to set AbsoluteExpirationRelativeToNow, SlidingExpiration, or both (sliding with an absolute cap).
Caching Patterns
Cache-Aside
The most common pattern: check cache first, fall back to the data source on a miss, then populate cache.
Direct Valkey Access
For counters and complex data structures, inject IConnectionMultiplexer directly. This approach is useful for high-performance increment operations, quota checks, and presence tracking.
Batched Operations
Use IDatabase.CreateBatch() for related operations that should be sent to Valkey in a single round-trip. The presence tracking service uses batching extensively.
Cache Invalidation
Three strategies are used:
- Event-driven invalidation: Wolverine handlers invalidate cache entries when domain events fire.
- Time-based expiration: Entries expire naturally via TTL.
- Write-through invalidation: Cache is cleared immediately after a database write.
Tenant-Scoped Caching
Always include tenant ID in cache keys for multi-tenant data to prevent cross-tenant data leaks.
SignalR Backplane
Valkey serves as the SignalR backplane for horizontal scaling. The channel prefix is configurable via SignalR:RedisPrefix (defaults to Wallow). The backplane reuses the singleton IConnectionMultiplexer.
When multiple API instances run behind a load balancer, the backplane ensures WebSocket messages reach all connected clients regardless of which instance they are connected to, using Valkey pub/sub channels.
Presence Tracking
The RedisPresenceService (api/src/Wallow.Api/Services/RedisPresenceService.cs) tracks online users and their current page context. All presence keys are tenant-scoped.
Key Structure
The full presence key table — patterns, Valkey types, and purposes — lives in Realtime, which owns the presence feature.
All presence keys use a 30-minute TTL as a safety net against orphaned entries.
API Key Storage
The RedisApiKeyService (api/src/Modules/ApiKeys/Wallow.ApiKeys.Infrastructure/Services/RedisApiKeyService.cs) stores service account API keys in Valkey for fast validation. API keys are hashed before storage; the raw key is never persisted.
Key Structure
| Key Pattern | Content | Expiry |
|---|---|---|
apikey:{hash} |
Full API key metadata | Optional (key expiration) |
apikey:id:{keyId} |
Same metadata by ID | Same as above |
apikeys:user:{userId} |
Set of keyIds | None |
Best Practices
- Use colons as separators:
domain:scope:identifier - Include tenant ID for multi-tenant data:
tenant:{tenantId}:resource:{id} - Stagger expiration with jitter to prevent synchronized cache stampedes
- Handle cache failures gracefully: cache should enhance performance, not be a hard dependency. Fall back to the database when Valkey is unavailable.
Health Checks
Valkey health is monitored via ASP.NET Core health checks, registered with the redis name and infrastructure + ready tags. Check status at /health/ready.
Testing
Unit Tests
Mock IDistributedCache or IConnectionMultiplexer using NSubstitute. For direct Valkey operations, mock IDatabase via the multiplexer.
Integration Tests
Use Testcontainers with the valkey/valkey:8-alpine image for integration tests that need a real Valkey instance. The test factory configures allowAdmin=true to enable FlushDatabaseAsync for state cleanup between tests.
Related Documentation
- Testing Guide
- Configuration Guide
- Realtime — the SignalR side of the backplane described above
- Authentication — the sign-in ticket replay guard that writes to Valkey