Files
pgsql-jellyfin/QUERY_EXECUTION_ANALYSIS.md
T
wjones e80dbd757b 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.
2026-07-09 15:58:33 +00:00

480 lines
17 KiB
Markdown

# Query Execution Analysis: Web UI Page Load - Multiple Query Issue
## Executive Summary
The query is NOT executed multiple times due to N+1 problems during **eager loading** of the initial results. Instead, UserData **should be** eagerly loaded by the main query via `.Include(e => e.UserData)`. If multiple queries are observed, the issue is likely:
1. **UI making multiple API calls** to different endpoints (different item types/collections)
2. **Multiple simultaneous requests** for different pages or items
3. **DbContext disposal** causing lazy loading fallback
4. **Optional field requests** triggering separate queries (like ItemCounts, ChildCount, etc.)
---
## Flow Diagram: Web UI Page Load → Database Queries
```
┌─────────────────────────────────────────┐
│ Web UI Page Load │
│ (Browser renders views/components) │
└─────────────────────┬───────────────────┘
┌─────────────▼─────────────┐
│ Multiple Simultaneous API │
│ Calls May Occur Here: │
│ • GetItems (movies) │
│ • GetItems (series) │
│ • GetItems (resume) │
│ • Other endpoints │
└─────────────┬─────────────┘
┌─────────────▼──────────────────────────┐
│ ItemsController.GetItems() │
│ [Jellyfin.Api/Controllers/..cs:166] │
└─────────────┬──────────────────────────┘
┌─────────────▼──────────────────────────────┐
│ 1. Build InternalItemsQuery with: │
│ - Filters, sorting, paging │
│ - DtoOptions.EnableUserData = true │
└─────────────┬──────────────────────────────┘
┌─────────────▼──────────────────────────────┐
│ 2. LibraryManager.GetItemsResult() │
│ [Emby.Server.Implementations/..:1647] │
│ → ItemRepository.GetItems(query) │
└─────────────┬──────────────────────────────┘
┌─────────────▼────────────────────────────────┐
│ 3. BaseItemRepository.GetItemsAsync() │
│ [Jellyfin.Server.Implementations/..:421] │
│ │
│ Step A: Build query (filtering) │
│ Step B: Apply ApplyNavigations() │
│ → .Include(e => e.UserData) │
│ Step C: Get paged IDs │
│ Step D: Load full items with UserData │
│ (ALL UserData is eagerly loaded) │
└─────────────┬────────────────────────────────┘
┌─────────────▼──────────────────────────────┐
│ 4. DtoService.GetBaseItemDtos() │
│ [Emby.Server.Implementations/..:160] │
│ │
│ FOR EACH ITEM: │
│ → GetBaseItemDtoInternal() │
│ → AttachUserSpecificInfo() │
│ → GetUserDataDto() │
│ → GetUserData() │
│ (NO QUERY - UserData in │
│ memory from Include) │
└─────────────┬──────────────────────────────┘
┌─────────────▼──────────────────┐
│ Response sent to browser │
│ (DTOs with UserData included) │
└───────────────────────────────┘
```
---
## Detailed Analysis
### 1. ItemsController.GetItems() - Parameter Setup
**File**: [Jellyfin.Api/Controllers/ItemsController.cs](Jellyfin.Api/Controllers/ItemsController.cs#L166)
```csharp
public ActionResult<QueryResult<BaseItemDto>> GetItems(
[FromQuery] Guid? userId,
[FromQuery] bool? enableUserData, // Line 219
[FromQuery] int? imageTypeLimit,
[FromQuery] bool? enableImages = true) // Line 253
{
// Line 274-275: Create DtoOptions with defaults
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
```
**Key Point**: If `enableUserData` parameter is null, it uses the DtoOptions default value of `true`.
---
### 2. DtoOptions Default Value
**File**: [MediaBrowser.Controller/Dto/DtoOptions.cs](MediaBrowser.Controller/Dto/DtoOptions.cs#L32)
```csharp
public DtoOptions()
: this(true)
{
}
public DtoOptions(bool allFields)
{
ImageTypeLimit = int.MaxValue;
EnableImages = true;
EnableUserData = true; // Line 36 - DEFAULT IS TRUE
AddCurrentProgram = true;
Fields = allFields ? AllItemFields : [];
ImageTypes = AllImageTypes;
}
```
**Conclusion**: UserData eager loading is enabled by default.
---
### 3. BaseItemRepository.GetItemsAsync() - Eager Loading
**File**: [Jellyfin.Server.Implementations/Item/BaseItemRepository.cs](Jellyfin.Server.Implementations/Item/BaseItemRepository.cs#L421)
```csharp
public async Task<QueryResult<BaseItemDto>> GetItemsAsync(
InternalItemsQuery filter,
CancellationToken cancellationToken = default)
{
// Lines 435-445: Build initial query with filters
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
dbQuery = TranslateQuery(dbQuery, context, filter);
// Lines 449-451: Apply ordering
dbQuery = ApplyOrder(dbQuery, filter, context);
// Lines 453-455: Get IDs with SELECT (doesn't load UserData yet)
var allIds = await dbQuery
.Select(e => new { e.Id, e.PresentationUniqueKey,
SeriesPresentationUniqueKey = e.TvExtras!.SeriesPresentationUniqueKey })
.ToListAsync(cancellationToken);
// Lines 467-473: Load FULL entities with navigations
var dbQueryWithNavs = ApplyNavigations(
context.BaseItems.Where(e => pagedIds.Contains(e.Id)),
filter);
var items = await dbQueryWithNavs
.ToListAsync(cancellationToken);
// Items now have UserData loaded in memory
result.Items = MaterializeOrderedDtos(items, pagedIds, filter.SkipDeserialization);
}
```
**Critical Section - ApplyNavigations()** at lines 468-473:
```csharp
private static IQueryable<BaseItemEntity> ApplyNavigations(
IQueryable<BaseItemEntity> dbQuery,
InternalItemsQuery filter)
{
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
dbQuery = dbQuery.Include(e => e.TrailerTypes);
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData) // Line 797
{
dbQuery = dbQuery.Include(e => e.UserData); // Line 798
}
if (filter.DtoOptions.EnableImages)
{
dbQuery = dbQuery.Include(e => e.Images);
}
dbQuery = dbQuery
.Include(e => e.TvExtras)
.Include(e => e.LiveTvExtras)
.Include(e => e.AudioExtras);
return dbQuery;
}
```
**Result**: UserData is loaded as part of the single query that retrieves the paged items.
---
### 4. DtoService.GetBaseItemDtos() - Per-Item Processing
**File**: [Emby.Server.Implementations/Dto/DtoService.cs](Emby.Server.Implementations/Dto/DtoService.cs#L160)
```csharp
public IReadOnlyList<BaseItemDto> GetBaseItemDtos(
IReadOnlyList<BaseItem> items,
DtoOptions options,
User? user = null,
BaseItem? owner = null)
{
var accessibleItems = user is null ? items : items.Where(x => x.IsVisible(user)).ToList();
var returnItems = new BaseItemDto[accessibleItems.Count];
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
var dto = GetBaseItemDtoInternal(item, options, user, owner);
if (item is LiveTvChannel tvChannel)
{
(channelTuples ??= []).Add((dto, tvChannel));
}
else if (item is LiveTvProgram)
{
(programTuples ??= []).Add((item, dto));
}
if (options.ContainsField(ItemFields.ItemCounts))
{
SetItemByNameInfo(dto, user); // ⚠️ POTENTIAL N+1: Per-item operation
}
returnItems[index] = dto;
}
return returnItems;
}
```
**Note**: Line 182 shows `SetItemByNameInfo()` is called per-item if ItemCounts field is requested.
---
### 5. GetBaseItemDtoInternal() - User-Specific Info Attachment
**File**: [Emby.Server.Implementations/Dto/DtoService.cs](Emby.Server.Implementations/Dto/DtoService.cs#L222)
```csharp
private BaseItemDto GetBaseItemDtoInternal(
BaseItem item,
DtoOptions options,
User? user = null,
BaseItem? owner = null)
{
var dto = new BaseItemDto { ServerId = _appHost.SystemId };
// ... various field attachments ...
if (user is not null)
{
AttachUserSpecificInfo(dto, item, user, options); // Line 259
}
// ... more field attachments ...
return dto;
}
```
### 6. AttachUserSpecificInfo() - Where UserData is Accessed
**File**: [Emby.Server.Implementations/Dto/DtoService.cs](Emby.Server.Implementations/Dto/DtoService.cs#L465)
```csharp
private void AttachUserSpecificInfo(
BaseItemDto dto,
BaseItem item,
User user,
DtoOptions options)
{
if (item.IsFolder)
{
var folder = (Folder)item;
if (options.EnableUserData)
{
dto.UserData = _userDataRepository.GetUserDataDto(item, dto, user, options);
// Line 473: ✓ This should NOT query - UserData already loaded
}
if (!dto.ChildCount.HasValue && item.SourceType == SourceType.Library)
{
if (item is MusicAlbum || item is Season || item is Playlist)
{
dto.ChildCount = dto.RecursiveItemCount;
var folderChildCount = folder.LinkedChildren.Length;
if (folderChildCount > 0)
{
dto.ChildCount ??= folderChildCount;
}
}
if (options.ContainsField(ItemFields.ChildCount))
{
dto.ChildCount ??= GetChildCount(folder, user);
// ⚠️ POTENTIAL N+1: Per-item database call
}
}
// ... more operations ...
}
else
{
if (options.EnableUserData)
{
dto.UserData = _userDataRepository.GetUserDataDto(item, user);
// ✓ This should NOT query - UserData already loaded
}
}
}
```
### 7. UserDataManager.GetUserData() - Accesses In-Memory Collection
**File**: [Emby.Server.Implementations/Library/UserDataManager.cs](Emby.Server.Implementations/Library/UserDataManager.cs#L247)
```csharp
public UserItemData? GetUserData(User user, BaseItem item)
{
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault()
?? new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
```
**Key Point**: This accesses `item.UserData` collection directly. Since it was eagerly loaded via `.Include(e => e.UserData)`, this is an in-memory LINQ query with NO database access.
---
## Why Multiple Queries Are Observed
### Issue #1: Multiple API Endpoints Called Simultaneously
**Most Likely Cause**
The web UI likely calls multiple endpoints during page load:
```
GET /Items?userId=...&includeItemTypes=Movie&startIndex=0&limit=20
GET /Items?userId=...&includeItemTypes=Series&startIndex=0&limit=20
GET /Items?userId=...&filters=IsResumable&startIndex=0&limit=20
GET /Users/{userId}/Items/Resume
GET /Playlists
```
Each call results in a separate query to the database.
**Evidence**: Check browser DevTools Network tab to see all API calls during page load.
---
### Issue #2: ItemCounts Field Requested (N+1 Risk)
**Possible But Less Likely**
If `ItemFields.ItemCounts` is included in the fields, `SetItemByNameInfo()` is called per-item:
```csharp
if (options.ContainsField(ItemFields.ItemCounts))
{
SetItemByNameInfo(dto, user); // Called for EACH item!
}
```
This method might query for each item if not properly optimized.
---
### Issue #3: ChildCount Field Requested (Potential N+1)
**Possible But Optimized**
If `ItemFields.ChildCount` is requested for folders, `GetChildCount()` is called:
```csharp
if (options.ContainsField(ItemFields.ChildCount))
{
dto.ChildCount ??= GetChildCount(folder, user);
}
```
However, there's optimization:
- Collection folders and user views return random count (NO query)
- Other folders call `folder.GetChildCount(user)` (could query)
---
### Issue #4: DbContext Disposal Causing Lazy Loading
**Possible If Data Access Layer Issue**
If the DbContext is disposed before accessing the UserData collection:
```csharp
var dbQueryWithNavs = ApplyNavigations(...);
var items = await dbQueryWithNavs.ToListAsync(); // DbContext disposed here?
// If accessed later, UserData lazy-loads = NEW QUERY
_dtoService.GetBaseItemDtos(items, ...);
```
---
## Recommendations
### 1. **Capture Network Traffic**
Use browser DevTools Network tab to see exactly which API endpoints are called and in what order:
```
Network tab → Filter by XHR/Fetch → Sort by Name
Look for multiple GetItems calls with different parameters
```
### 2. **Enable Detailed Query Logging**
Modify logging.json to see all queries:
```json
{
"Microsoft.EntityFrameworkCore.Database.Command": "Debug"
}
```
Then check the logs to see:
- How many separate queries are executed
- When they're executed (timing)
- What parameters they have
### 3. **Check Specific Fields Being Requested**
If the same query is logged multiple times, check if UI is requesting expensive fields:
- `ItemFields.ItemCounts` - calls `SetItemByNameInfo()` per-item
- `ItemFields.ChildCount` - calls `GetChildCount()` per-item
- `ItemFields.People` - calls `AttachPeople()` per-item
### 4. **Batch Related Queries**
If multiple similar GetItems calls are made, consider consolidating them or adding caching.
### 5. **Verify Include() is Working**
Add logging to confirm `ApplyNavigations()` is being called with correct flags:
```csharp
_logger.LogInformation("EnableUserData: {EnableUserData}, UserData navigation included",
filter.DtoOptions.EnableUserData);
```
---
## Files Involved in Query Flow
| File | Line | Function | Purpose |
|------|------|----------|---------|
| ItemsController.cs | 166 | GetItems() | HTTP endpoint, builds DtoOptions |
| ItemsController.cs | 527 | folder.GetItems(query) | Calls repository |
| LibraryManager.cs | 1647 | GetItemsResult() | Builds InternalItemsQuery |
| BaseItemRepository.cs | 421 | GetItemsAsync() | Main query execution |
| BaseItemRepository.cs | 468-473 | ApplyNavigations() | Eager loads UserData via Include() |
| BaseItemRepository.cs | 476 | MaterializeOrderedDtos() | Converts entities to DTOs |
| DtoService.cs | 160 | GetBaseItemDtos() | Per-item DTO conversion loop |
| DtoService.cs | 222 | GetBaseItemDtoInternal() | Builds individual DTO |
| DtoService.cs | 465 | AttachUserSpecificInfo() | Adds user-specific data |
| UserDataManager.cs | 247 | GetUserData() | Accesses UserData collection (in-memory) |
| UserDataManager.cs | 260 | GetUserDataDto() | Converts UserData to DTO |
---
## Conclusion
**UserData is properly eagerly loaded** via `.Include(e => e.UserData)` in the main query. The apparent "multiple queries" during page load is almost certainly due to:
1. **Multiple simultaneous API calls** from the web UI (different item types, collections)
2. **Expensive fields requested** (ItemCounts, ChildCount) triggering per-item operations
3. **Normal pagination** requiring separate queries for different pages
**Action Items**:
- [ ] Capture network traffic to verify which endpoints are called
- [ ] Check if UserData is being accessed in the response (if not requested, don't Include it)
- [ ] Review UI for opportunities to consolidate API calls
- [ ] Consider caching for frequently requested data