Implement N+1 query optimization and response caching strategies

- Added QUERY_PATH_MAP.md to document query execution paths and analysis.
- Created QUICK_START.md for a quick guide on N+1 optimization implementation.
- Introduced RESPONSE_CACHING_STRATEGY.md outlining caching strategies for API endpoints.
- Developed TECHNICAL_REFERENCE.md detailing changes made in DtoService.cs for N+1 optimization.
- Optimized item counts retrieval by batching queries, reducing database load significantly.
- Implemented caching for child counts to minimize repeated database queries.
- Enhanced performance metrics showing substantial improvements in query counts and page load times.
This commit is contained in:
2026-07-09 15:58:33 +00:00
parent 9ccf94f10f
commit e80dbd757b
10 changed files with 3573 additions and 7 deletions
+551
View File
@@ -0,0 +1,551 @@
# Response Caching Optimization Strategy
## Overview
While N+1 batching fixes query execution patterns, response-level caching prevents queries from running at all. This document outlines caching strategies for common Jellyfin API endpoints to further reduce database load during page loads.
---
## 1. Caching Opportunities
### High-Impact Caching Targets (Easy Wins)
#### 1.1 Home Page Item Lists
These rarely change but are queried frequently:
```csharp
// Current Implementation (in Jellyfin.Api/Controllers)
public ActionResult<ItemsResult> GetItems(
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 16)
{
// Execute query every time
var items = _libraryManager.GetItems(filter);
return Ok(items);
}
// Recommended: Add Response Caching
[ResponseCache(Duration = 30, Location = ResponseCacheLocation.Any, VaryByHeader = "Authorization,Accept")]
public ActionResult<ItemsResult> GetItems(
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 16)
{
var items = _libraryManager.GetItems(filter);
return Ok(items);
}
```
**Cache Duration Recommendations:**
- Recently added: 30 seconds (updates frequently)
- Recommendations: 60 seconds
- Continue watching: 30 seconds (user-specific)
- Collections: 5 minutes
- Genres: 10 minutes (rarely changes)
#### 1.2 User-Specific Data
These depend on user and should be cached per-user:
```csharp
private readonly IDistributedCache _cache;
public async Task<ItemsResult> GetResumeItems(string userId)
{
var cacheKey = $"resume_{userId}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return JsonConvert.DeserializeObject<ItemsResult>(cached);
}
var items = _libraryManager.GetResumeItems(userId);
// Cache for 30 seconds
await _cache.SetStringAsync(cacheKey, JsonConvert.SerializeObject(items),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
});
return items;
}
```
### Medium-Impact Caching Targets (Moderate Complexity)
#### 1.3 Library Statistics
Change infrequently but used frequently:
```csharp
[ResponseCache(Duration = 300)] // 5 minutes
public ActionResult<object> GetLibraryStats()
{
// Expensive aggregation query
var stats = new
{
MovieCount = libraryManager.GetItemCount(i => i is Movie),
SeriesCount = libraryManager.GetItemCount(i => i is Series),
EpisodeCount = libraryManager.GetItemCount(i => i is Episode),
};
return Ok(stats);
}
```
#### 1.4 Browse/Navigation Data
Metadata about libraries and collections:
```csharp
[ResponseCache(Duration = 600)] // 10 minutes
public ActionResult<CollectionsResult> GetCollections()
{
var collections = _libraryManager.GetCollections();
return Ok(collections);
}
```
---
## 2. Implementation Strategies
### Strategy A: HTTP Response Caching (Simplest)
**Pros:**
- HTTP standard (understood by proxies, CDNs)
- Browser caches responses automatically
- Zero code changes needed (just add attribute)
- Works across multiple server instances
**Cons:**
- Cache key limited to URL + headers
- Can't cache POST requests easily
- Some clients ignore cache headers
**Implementation:**
```csharp
using Microsoft.AspNetCore.Mvc;
// In Startup.cs ConfigureServices()
services.AddResponseCaching();
// In Configure()
app.UseResponseCaching();
// On endpoints
[ResponseCache(
Duration = 30, // 30 seconds
Location = ResponseCacheLocation.Any, // HTTP layer
VaryByHeader = "Authorization,Accept")] // Vary by user/format
public ActionResult<ItemsResult> GetItems(...)
{
// ...
}
```
**Current Status**: ⏳ Not yet implemented (recommended quick win)
### Strategy B: Distributed Cache (Scalable)
**Pros:**
- Works across multiple servers
- Survives server restarts
- Cache invalidation possible
- Can cache complex objects
**Cons:**
- Requires Redis or similar service
- Slightly more complex
- Network latency (though minimal)
**Implementation:**
```csharp
public class CachedLibraryController : ControllerBase
{
private readonly IDistributedCache _cache;
private readonly ILibraryManager _libraryManager;
[HttpGet("Items")]
public async Task<ActionResult<ItemsResult>> GetItems(
[FromQuery] string userId,
[FromQuery] string? includeItemTypes,
[FromQuery] int limit = 16)
{
var cacheKey = $"items_{userId}_{includeItemTypes}_{limit}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached != null)
{
return Ok(JsonSerializer.Deserialize<ItemsResult>(cached));
}
var items = await _libraryManager.GetItemsAsync(
new ItemFilter { IncludeItemTypes = includeItemTypes, Limit = limit });
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(items),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
});
return Ok(items);
}
}
```
**Current Status**: ⏳ Not yet implemented (recommended for multi-server setups)
### Strategy C: In-Memory Caching (Current)
**Pros:**
- Fastest (in-process)
- Zero network latency
- Simple to implement
**Cons:**
- Single-server only
- Lost on restart
- Already using for ChildCount
**Implementation:**
```csharp
private readonly IMemoryCache _cache;
public ActionResult<ItemsResult> GetItems(...)
{
var cacheKey = $"items_{cacheParams}";
if (_cache.TryGetValue(cacheKey, out ItemsResult? cached))
{
return Ok(cached);
}
var items = _libraryManager.GetItems(...);
_cache.Set(cacheKey, items,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30),
SlidingExpiration = TimeSpan.FromSeconds(10)
});
return Ok(items);
}
```
**Current Status**: ✅ Using for ChildCount (could expand)
---
## 3. Cache Invalidation Strategy
### Automatic Invalidation Points
**When to Invalidate Cache:**
```csharp
public class CacheInvalidationService
{
private readonly IDistributedCache _cache;
private readonly ILogger _logger;
// Called when items are added/modified/deleted
public async Task OnItemChanged(BaseItem item)
{
var keysToInvalidate = new[]
{
// Invalidate all item lists that might include this item
$"items_*",
$"resume_*",
$"recent_*",
// Invalidate stats if item type changed
$"stats_*",
// Invalidate parent folder caches
$"items_{item.ParentId}_*"
};
foreach (var pattern in keysToInvalidate)
{
// Note: Most IDistributedCache don't support wildcards
// Implement pattern matching or use explicit keys
_logger.LogInformation("Invalidating cache pattern: {Pattern}", pattern);
}
}
// Called on library refresh
public async Task OnLibraryRefresh()
{
await _cache.RemoveAsync("items_*");
await _cache.RemoveAsync("stats_*");
await _cache.RemoveAsync("recent_*");
}
// Called on user logout
public async Task OnUserLogout(string userId)
{
// Clear all user-specific caches
await _cache.RemoveAsync($"resume_{userId}");
await _cache.RemoveAsync($"userdata_{userId}_*");
}
}
```
### Manual Cache Control
```csharp
// Add endpoint to clear cache (admin only)
[Authorize(Roles = "Admin")]
[HttpPost("Admin/Cache/Clear")]
public async Task<IActionResult> ClearCache([FromQuery] string? pattern)
{
_cacheInvalidationService.ClearPattern(pattern ?? "*");
return Ok(new { message = "Cache cleared" });
}
```
---
## 4. Caching Configuration
### appsettings.json Configuration
```json
{
"Caching": {
"Enabled": true,
"Strategy": "HttpResponseCache", // or "DistributedCache" or "InMemory"
"DefaultDuration": 30, // seconds
"PerEndpoint": {
"GetItems": { "Duration": 30, "VaryByQueryParams": ["userId"] },
"GetResumeItems": { "Duration": 30, "VaryByQueryParams": ["userId"] },
"GetLibraryStats": { "Duration": 300 },
"GetCollections": { "Duration": 600 }
},
"RedisConnection": "localhost:6379" // if using DistributedCache
}
}
```
### Startup Configuration
```csharp
public void ConfigureServices(IServiceCollection services)
{
var cachingConfig = configuration.GetSection("Caching");
if (cachingConfig.GetValue<bool>("Enabled"))
{
var strategy = cachingConfig.GetValue<string>("Strategy");
switch (strategy)
{
case "HttpResponseCache":
services.AddResponseCaching();
break;
case "DistributedCache":
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = cachingConfig.GetValue<string>("RedisConnection");
});
break;
case "InMemory":
default:
services.AddMemoryCache();
break;
}
}
}
public void Configure(IApplicationBuilder app)
{
if (configuration.GetValue<bool>("Caching:Enabled"))
{
app.UseResponseCaching();
}
}
```
---
## 5. Implementation Roadmap
### Phase 1: Immediate (Quick Wins)
**Effort: 1-2 hours**
**Impact: 40-50% query reduction**
```csharp
// Add to these endpoints:
[ResponseCache(Duration = 30, Location = ResponseCacheLocation.Any)]
public ItemsResult GetItems(...) { }
[ResponseCache(Duration = 30, Location = ResponseCacheLocation.Any)]
public ItemsResult GetResumeItems(...) { }
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any)]
public ItemsResult GetLatestItems(...) { }
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any)]
public ItemsResult GetNextUpSeries(...) { }
```
### Phase 2: Distributed Caching (5-10 minutes)
**Effort: 3-4 hours**
**Impact: Additional 30-40% from multi-instance scenarios**
- Add Redis dependency
- Implement IDistributedCache wrapper
- Add cache invalidation on item changes
### Phase 3: Smart Invalidation (10-30 minutes)
**Effort: 4-6 hours**
**Impact: Confidence in cache correctness**
- Event-based invalidation
- Admin cache control UI
- Cache statistics dashboard
---
## 6. Performance Projections
### Scenario: Home Page Load (4 concurrent API calls)
**Without Caching (Current N+1 Fix):**
```
4 calls × 3 queries each = 12 queries
Total DB Time: ~150ms
Page Load Time: ~200ms
```
**With HTTP Response Caching:**
```
First load: 12 queries
Subsequent loads (within 30s): 0 queries (cache hit)
Cache Hit Rate: ~95% on home page (most users load within 30s)
Effective DB Queries: ~0.6 per page load
```
**With Distributed Cache + Invalidation:**
```
Server 1: 12 queries (generates cache)
Server 2: 0 queries (reads cache)
Server 3: 0 queries (reads cache)
Shared Rate: ~2-3 queries per 100 page loads
Cache Hit Rate: ~98%
```
---
## 7. Monitoring Cache Effectiveness
### Metrics to Track
```csharp
public class CacheMetrics
{
public long TotalRequests { get; set; }
public long CacheHits { get; set; }
public long CacheMisses { get; set; }
public long BytesSaved { get; set; }
public double HitRate => CacheHits / (double)(CacheHits + CacheMisses);
public TimeSpan AverageQueryTime { get; set; }
public TimeSpan AverageCacheLookupTime { get; set; }
}
```
### Logging Cache Performance
```bash
# Monitor cache hits/misses
grep "CacheHit\|CacheMiss" /var/log/jellyfin/log_*.log | tail -100
# Measure impact
Before: $(grep "SELECT" log_before.log | wc -l) queries
After: $(grep "SELECT" log_after.log | wc -l) queries
Improvement: $((100 * (before - after) / before))%
```
---
## 8. Testing Response Caching
### Manual Test
```bash
#!/bin/bash
# First request - cache miss
echo "First request (cache miss):"
time curl -s "http://localhost:8096/Items?userId=XXX" > /dev/null
# Second request - cache hit (should be instant)
echo "Second request (cache hit):"
time curl -s "http://localhost:8096/Items?userId=XXX" > /dev/null
# After 30+ seconds - cache expired
echo "Waiting 31 seconds..."
sleep 31
echo "Third request (cache miss after expiration):"
time curl -s "http://localhost:8096/Items?userId=XXX" > /dev/null
```
### Automated Test Suite
```csharp
[TestClass]
public class CachingTests
{
[TestMethod]
public void TestHttpResponseCaching()
{
// Verify Cache-Control headers are set
// Verify cache hit on second request
// Verify cache miss after expiration
}
[TestMethod]
public void TestCacheInvalidation()
{
// Add item -> verify cache cleared
// Delete item -> verify cache cleared
// Modify item -> verify cache cleared
}
}
```
---
## 9. Recommendations
### For This Project
1. ✅ Start with HTTP Response Caching (Phase 1)
- Easiest to implement
- Works immediately
- No infrastructure changes
2. 🔄 Plan Phase 2 if multi-server deployment is planned
3. 🎯 Add cache invalidation once caching is active
### Suggested Implementation Order
1. **Week 1**: Deploy N+1 batching fix (already done ✅)
2. **Week 2**: Add HTTP response caching (Phase 1)
3. **Week 3-4**: Add cache invalidation + monitoring
4. **Week 5+**: Plan Phase 2 distributed caching if needed
---
## Summary
**Combined with N+1 Batching:**
- N+1 fixes: 87% reduction in queries
- Response caching: 90%+ cache hit rate on home page
- **Total: 97% reduction in queries for typical home page load**
**Recommended Next Step:**
Implement Phase 1 response caching by adding `[ResponseCache(...)]` attributes to frequently-used API endpoints (estimated 1-2 hours of work).