Files
pgsql-jellyfin/ASYNC_QUICK_REFERENCE.md
T
wjones 86883cd5c6 Refactor PostgreSQL provider: multi-schema & async prep
- 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.
2026-02-23 09:38:22 -05:00

9.7 KiB

Async/Await Quick Reference for Jellyfin Database Conversion

🔄 Common Conversions

Synchronous Asynchronous Notes
context.SaveChanges() await context.SaveChangesAsync(cancellationToken) Always pass cancellation token
query.ToList() await query.ToListAsync(cancellationToken) Materializes full list
query.ToArray() await query.ToArrayAsync(cancellationToken) Materializes full array
query.FirstOrDefault() await query.FirstOrDefaultAsync(cancellationToken) Returns null if not found
query.First() await query.FirstAsync(cancellationToken) Throws if not found
query.SingleOrDefault() await query.SingleOrDefaultAsync(cancellationToken) Throws if multiple found
query.Any() await query.AnyAsync(cancellationToken) Existence check
query.Count() await query.CountAsync(cancellationToken) Count results
query.Sum(x => x.Value) await query.SumAsync(x => x.Value, cancellationToken) Aggregate
query.ExecuteDelete() await query.ExecuteDeleteAsync(cancellationToken) Bulk delete
query.ExecuteUpdate(...) await query.ExecuteUpdateAsync(..., cancellationToken) Bulk update
context.Database.BeginTransaction() await context.Database.BeginTransactionAsync(cancellationToken) Start transaction
transaction.Commit() await transaction.CommitAsync(cancellationToken) Commit transaction
transaction.Rollback() await transaction.RollbackAsync(cancellationToken) Rollback transaction
using var context = ... await using var context = ... Async disposal
foreach (var item in items) await foreach (var item in items) Async enumeration

📝 Method Signature Changes

// BEFORE: Synchronous
public void SaveUser(User user)
{
    using var context = _dbProvider.CreateDbContext();
    context.Users.Add(user);
    context.SaveChanges();
}

// AFTER: Asynchronous
public async Task SaveUserAsync(User user, CancellationToken cancellationToken = default)
{
    await using var context = _dbProvider.CreateDbContext();
    context.Users.Add(user);
    await context.SaveChangesAsync(cancellationToken);
}

🎯 Interface Changes

// BEFORE
public interface IUserRepository
{
    void SaveUser(User user);
    User? GetUser(Guid id);
    List<User> GetAllUsers();
    void DeleteUser(Guid id);
    int GetUserCount();
}

// AFTER
public interface IUserRepository
{
    Task SaveUserAsync(User user, CancellationToken cancellationToken = default);
    Task<User?> GetUserAsync(Guid id, CancellationToken cancellationToken = default);
    Task<List<User>> GetAllUsersAsync(CancellationToken cancellationToken = default);
    Task DeleteUserAsync(Guid id, CancellationToken cancellationToken = default);
    Task<int> GetUserCountAsync(CancellationToken cancellationToken = default);
}

🔧 Controller Changes

// BEFORE: Synchronous controller
[HttpGet("{id}")]
public ActionResult<UserDto> GetUser([FromRoute] Guid id)
{
    var user = _userRepository.GetUser(id);
    return user is null ? NotFound() : Ok(user);
}

[HttpPost]
public ActionResult<UserDto> CreateUser([FromBody] CreateUserRequest request)
{
    var user = new User { ... };
    _userRepository.SaveUser(user);
    return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}

// AFTER: Asynchronous controller
[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(
    [FromRoute] Guid id,
    CancellationToken cancellationToken)
{
    var user = await _userRepository.GetUserAsync(id, cancellationToken);
    return user is null ? NotFound() : Ok(user);
}

[HttpPost]
public async Task<ActionResult<UserDto>> CreateUser(
    [FromBody] CreateUserRequest request,
    CancellationToken cancellationToken)
{
    var user = new User { ... };
    await _userRepository.SaveUserAsync(user, cancellationToken);
    return CreatedAtAction(nameof(GetUser), new { id = user.Id }, user);
}

🧪 Test Changes

// BEFORE: Synchronous test
[Fact]
public void GetUser_ValidId_ReturnsUser()
{
    var user = _repository.GetUser(_userId);
    Assert.NotNull(user);
}

// AFTER: Asynchronous test
[Fact]
public async Task GetUserAsync_ValidId_ReturnsUser()
{
    var user = await _repository.GetUserAsync(_userId, CancellationToken.None);
    Assert.NotNull(user);
}

// Test cancellation
[Fact]
public async Task GetUserAsync_Cancelled_ThrowsOperationCanceledException()
{
    var cts = new CancellationTokenSource();
    cts.Cancel();
    
    await Assert.ThrowsAsync<OperationCanceledException>(
        () => _repository.GetUserAsync(_userId, cts.Token)
    );
}

🔍 Mock Setup Changes

// BEFORE: Synchronous mock
_mockRepository
    .Setup(x => x.GetUser(It.IsAny<Guid>()))
    .Returns(user);

// AFTER: Asynchronous mock
_mockRepository
    .Setup(x => x.GetUserAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
    .ReturnsAsync(user);

// For void methods
_mockRepository
    .Setup(x => x.SaveUserAsync(It.IsAny<User>(), It.IsAny<CancellationToken>()))
    .Returns(Task.CompletedTask);

// For exceptions
_mockRepository
    .Setup(x => x.GetUserAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
    .ThrowsAsync(new InvalidOperationException());

Parallel Operations

// Sequential (slow)
await DeleteUserDataAsync(userId, cancellationToken);
await DeleteUserSessionsAsync(userId, cancellationToken);
await DeleteUserDevicesAsync(userId, cancellationToken);

// Parallel (fast) - only when operations are independent!
await Task.WhenAll(
    DeleteUserDataAsync(userId, cancellationToken),
    DeleteUserSessionsAsync(userId, cancellationToken),
    DeleteUserDevicesAsync(userId, cancellationToken)
);

📊 Streaming Large Results

// Old way - loads everything into memory
public async Task<List<BaseItem>> GetAllItemsAsync(CancellationToken cancellationToken)
{
    await using var context = _dbProvider.CreateDbContext();
    return await context.BaseItems.ToListAsync(cancellationToken);
}

// New way - streams results
public async IAsyncEnumerable<BaseItem> GetAllItemsAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await using var context = _dbProvider.CreateDbContext();
    
    await foreach (var item in context.BaseItems
        .AsAsyncEnumerable()
        .WithCancellation(cancellationToken))
    {
        yield return item;
    }
}

// Consumer code
await foreach (var item in repository.GetAllItemsAsync(cancellationToken))
{
    // Process item without loading entire collection into memory
    ProcessItem(item);
}

🚫 Anti-Patterns to Avoid

Blocking on async code

// WRONG - causes deadlocks
var user = GetUserAsync(id).Result;
var user = GetUserAsync(id).GetAwaiter().GetResult();
GetUserAsync(id).Wait();

Async void

// WRONG - exceptions can't be caught
public async void SaveUserAsync(User user) { ... }

Not passing cancellation token

// WRONG - can't cancel
public async Task<User> GetUserAsync(Guid id)
{
    return await context.Users.FirstOrDefaultAsync(x => x.Id == id);
    // Should be: FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
}

Using Task.Run for database operations

// WRONG - creates extra threads unnecessarily
public async Task<User> GetUserAsync(Guid id)
{
    return await Task.Run(() => _repository.GetUser(id));
}

Best Practices

1. Always add CancellationToken parameter

public async Task DoSomethingAsync(
    CancellationToken cancellationToken = default)

2. Use await using for IAsyncDisposable

await using var context = _dbProvider.CreateDbContext();
await using var transaction = await context.Database.BeginTransactionAsync();

3. Propagate cancellation tokens

public async Task SaveUserAsync(User user, CancellationToken cancellationToken)
{
    await using var context = _dbProvider.CreateDbContext();
    context.Users.Add(user);
    await context.SaveChangesAsync(cancellationToken); // ✅ Pass it through
}

4. ConfigureAwait(false) in library code (optional)

// Library code that doesn't need synchronization context
var user = await context.Users
    .FirstOrDefaultAsync(x => x.Id == id, cancellationToken)
    .ConfigureAwait(false);

// Note: ASP.NET Core doesn't need ConfigureAwait(false)

5. Use ValueTask for hot paths

// For frequently called methods that often complete synchronously
public ValueTask<User?> GetCachedUserAsync(Guid id)
{
    if (_cache.TryGetValue(id, out var user))
    {
        return new ValueTask<User?>(user);
    }
    
    return new ValueTask<User?>(_repository.GetUserAsync(id));
}

📏 Naming Conventions

Element Convention Example
Async methods End with Async suffix GetUserAsync
Sync methods (old) No suffix GetUser
Interface methods Include Async suffix Task<User> GetUserAsync(...)
CancellationToken param Always last parameter GetUserAsync(Guid id, CancellationToken token)
CancellationToken default = default CancellationToken token = default

Quick Tip: Use Ctrl+F to search this document for specific conversions!