86883cd5c6
- Refactor migrations and provider to use multiple PostgreSQL schemas, each matching a legacy SQLite database (activitylog, authentication, displaypreferences, library, users). - All tables, foreign keys, and indexes are now schema-qualified; Down migration drops tables by schema. - Provider ensures schemas exist before migrations; entities are mapped to correct schemas in OnModelCreating. - Add support for max-pool-size, min-pool-size, and multiplexing connection options; update logging accordingly. - VACUUM ANALYZE now runs per schema during scheduled optimization. - TruncateAllTablesAsync now truncates tables with schema qualification. - README updated with schema structure, new options, and multiplexing warnings. - CacheDecorator now calls async repository methods using .GetAwaiter().GetResult(), with documentation. - Lays groundwork for full async/await and multiplexing support in the database layer.
9.4 KiB
9.4 KiB
Jellyfin Async Migration Plan
🎯 Objective
Convert Jellyfin database operations from synchronous to asynchronous to enable PostgreSQL multiplexing and improve performance.
📋 Migration Phases
Phase 1: Repository Interfaces (Breaking Changes)
Estimated Effort: 2-3 weeks
Files to Update:
MediaBrowser.Controller\Persistence\IItemRepository.csMediaBrowser.Controller\Library\IUserManager.csMediaBrowser.Controller\Persistence\IUserDataRepository.cs- All other repository interfaces
Changes:
// BEFORE
void DeleteItem(params IReadOnlyList<Guid> ids);
void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken);
BaseItem RetrieveItem(Guid id);
QueryResult<BaseItem> GetItems(InternalItemsQuery filter);
// AFTER
Task DeleteItemAsync(IReadOnlyList<Guid> ids, CancellationToken cancellationToken = default);
Task SaveItemsAsync(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken = default);
Task<BaseItem?> RetrieveItemAsync(Guid id, CancellationToken cancellationToken = default);
Task<QueryResult<BaseItem>> GetItemsAsync(InternalItemsQuery filter, CancellationToken cancellationToken = default);
Phase 2: Repository Implementations
Estimated Effort: 3-4 weeks
Files to Update:
Jellyfin.Server.Implementations\Item\BaseItemRepository.csJellyfin.Server.Implementations\Users\UserManager.cs- All repository implementation classes
Key Changes:
1. SaveChanges → SaveChangesAsync
// BEFORE
context.SaveChanges();
transaction.Commit();
// AFTER
await context.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
2. ExecuteDelete → ExecuteDeleteAsync
// BEFORE
context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
// AFTER
await context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId)
.ExecuteDeleteAsync(cancellationToken);
3. ExecuteUpdate → ExecuteUpdateAsync
// BEFORE
context.UserData.ExecuteUpdate(e => e
.SetProperty(f => f.RetentionDate, date));
// AFTER
await context.UserData.ExecuteUpdateAsync(e => e
.SetProperty(f => f.RetentionDate, date), cancellationToken);
4. ToArray/ToList → ToArrayAsync/ToListAsync
// BEFORE
var items = context.BaseItems.Where(x => x.Id == id).ToArray();
// AFTER
var items = await context.BaseItems
.Where(x => x.Id == id)
.ToArrayAsync(cancellationToken);
5. FirstOrDefault → FirstOrDefaultAsync
// BEFORE
var item = context.BaseItems.FirstOrDefault(x => x.Id == id);
// AFTER
var item = await context.BaseItems
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
6. Any → AnyAsync
// BEFORE
if (context.BaseItems.Any(x => x.Id == id))
// AFTER
if (await context.BaseItems.AnyAsync(x => x.Id == id, cancellationToken))
7. Count → CountAsync
// BEFORE
var count = context.BaseItems.Count();
// AFTER
var count = await context.BaseItems.CountAsync(cancellationToken);
Phase 3: Service Layer
Estimated Effort: 4-6 weeks
Files to Update:
Emby.Server.Implementations\Library\LibraryManager.csEmby.Server.Implementations\Library\MediaSourceManager.cs- All service classes that call repositories
Pattern:
// BEFORE
public BaseItem GetItem(Guid id)
{
return _itemRepository.RetrieveItem(id);
}
// AFTER
public async Task<BaseItem?> GetItemAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _itemRepository.RetrieveItemAsync(id, cancellationToken);
}
Phase 4: API Controllers
Estimated Effort: 3-4 weeks
Files to Update:
Jellyfin.Api\Controllers\ItemsController.csJellyfin.Api\Controllers\UsersController.cs- All API controller classes
Pattern:
// BEFORE
[HttpGet("{id}")]
public ActionResult<BaseItemDto> GetItem([FromRoute] Guid id)
{
var item = _libraryManager.GetItem(id);
return item is null ? NotFound() : Ok(item);
}
// AFTER
[HttpGet("{id}")]
public async Task<ActionResult<BaseItemDto>> GetItem(
[FromRoute] Guid id,
CancellationToken cancellationToken)
{
var item = await _libraryManager.GetItemAsync(id, cancellationToken);
return item is null ? NotFound() : Ok(item);
}
Phase 5: Background Services & Scheduled Tasks
Estimated Effort: 2-3 weeks
Files to Update:
Emby.Server.Implementations\ScheduledTasks\*- Background services using repositories
Pattern:
// Ensure ExecuteAsync uses await for all database operations
public async Task ExecuteAsync(CancellationToken cancellationToken)
{
await _itemRepository.UpdateInheritedValuesAsync(cancellationToken);
}
🧪 Testing Strategy
1. Unit Tests
- Update all repository unit tests to async
- Add tests for cancellation token handling
- Verify no synchronous database calls remain
2. Integration Tests
- Test full request pipeline (API → Service → Repository → Database)
- Verify async behavior under load
- Test cancellation scenarios
3. Performance Testing
- Compare performance before/after migration
- Test with multiplexing enabled vs disabled
- Monitor connection pool usage
📦 Migration Tools & Utilities
Automated Detection Script
# Find synchronous EF Core operations
Get-ChildItem -Recurse -Filter *.cs |
Select-String -Pattern "\.ToList\(\)|\.ToArray\(\)|\.FirstOrDefault\(\)|\.SaveChanges\(\)|\.ExecuteDelete\(\)|\.ExecuteUpdate\(\)|\.Any\(\)|\.Count\(\)" |
Where-Object { $_.Line -notmatch "Async" } |
Select-Object Path, LineNumber, Line
Regex Patterns for Find/Replace
# SaveChanges → SaveChangesAsync
Find: \.SaveChanges\(\);
Replace: await .SaveChangesAsync(cancellationToken);
# ToArray → ToArrayAsync
Find: \.ToArray\(\);
Replace: await .ToArrayAsync(cancellationToken);
# FirstOrDefault → FirstOrDefaultAsync
Find: \.FirstOrDefault\(
Replace: await .FirstOrDefaultAsync(
🚧 Breaking Changes & Compatibility
API Versioning
- Consider maintaining v1 API with sync methods
- Introduce v2 API with async methods
- Deprecation timeline for v1
Plugin Compatibility
- Plugins using sync methods will break
- Provide migration guide for plugin developers
- Consider compatibility shims for transition period
📊 Effort Estimation Summary
| Phase | Estimated Time | Risk Level | Priority |
|---|---|---|---|
| Phase 1: Interfaces | 2-3 weeks | High | Critical |
| Phase 2: Repositories | 3-4 weeks | High | Critical |
| Phase 3: Services | 4-6 weeks | Medium | High |
| Phase 4: Controllers | 3-4 weeks | Low | High |
| Phase 5: Background | 2-3 weeks | Medium | Medium |
| Testing | 2-3 weeks | High | Critical |
| Total | 16-23 weeks | - | - |
✅ Success Criteria
- ✅ Zero synchronous database operations in hot paths
- ✅ All repository methods are async
- ✅ All API endpoints are async
- ✅ PostgreSQL multiplexing can be enabled without errors
- ✅ Performance improvements measurable
- ✅ All tests passing
- ✅ No increase in API latency
🎯 Quick Win: Start Small
Proof of Concept (1-2 weeks)
Convert a single, isolated feature to async:
Recommended Starting Point: Activity Log Repository
- Relatively simple
- Limited dependencies
- Easy to test
- Low risk
// Convert ActivityLogRepository
public interface IActivityLogRepository
{
Task<ActivityLog?> GetAsync(int id, CancellationToken cancellationToken = default);
Task<QueryResult<ActivityLog>> GetAllAsync(
ActivityLogFilter filter,
CancellationToken cancellationToken = default);
Task CreateAsync(ActivityLog entry, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
🔧 PostgreSQL Multiplexing Benefits
Once fully async, enable multiplexing:
{
"Database": {
"CustomProviderOptions": {
"Options": [
{ "Key": "multiplexing", "Value": "true" },
{ "Key": "max-pool-size", "Value": "50" }
]
}
}
}
Expected Benefits:
- ⚡ 20-40% reduction in connection pool usage
- ⚡ Better throughput under high concurrency
- ⚡ Lower memory footprint
- ⚡ Improved scalability
📚 Resources
⚠️ Risks & Mitigation
| Risk | Impact | Mitigation |
|---|---|---|
| Breaking plugin API | High | Version API, provide migration guide |
| Deadlocks from improper async | High | Code review, static analysis |
| Performance regression | Medium | Extensive performance testing |
| Testing gaps | High | Increase test coverage before migration |
| Team learning curve | Medium | Training sessions, pair programming |
🎓 Team Onboarding
Required Knowledge
- C# async/await patterns
- Task-based asynchronous programming
- CancellationToken usage
- EF Core async methods
- Async enumeration (IAsyncEnumerable)
Training Resources
- Microsoft Learn: Async Programming
- Jellyfin async migration guide (to be created)
- Code review checklist
- Pair programming sessions
Document Version: 1.0
Last Updated: 2025-01-15
Owner: Database Team
Status: Planning