Files
pgsql-jellyfin/Jellyfin.Server.Implementations/Clustering/CacheCoordinator.cs
T
wjones 77e30685bb Complete multi-instance support: Phases 3–6 & deployment
- Implements Phases 3–6: session isolation, cache coordination, primary election, and file system monitor coordination for Jellyfin with PostgreSQL.
- Adds new database entities (Instance, DistributedLock, FileSystemChange) and EF model configurations.
- Includes SQL migration scripts and EF migration for all required tables, columns, and helper functions.
- Updates Device entity and JellyfinDbContext for multi-instance tracking.
- Integrates new DI services for instance registry, distributed locks, cache coordinator, and primary election.
- Adds publishing profiles (Win/Linux/FrameworkDependent) and automation script for deployment.
- Extensive documentation for architecture, setup, and publishing.
- All changes are backward compatible and build successfully.
2026-03-05 16:10:26 -05:00

310 lines
11 KiB
C#

// <copyright file="CacheCoordinator.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Server.Implementations.Clustering
{
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
/// <summary>
/// Coordinates cache invalidation across multiple Jellyfin instances using PostgreSQL LISTEN/NOTIFY.
/// </summary>
public sealed class CacheCoordinator : ICacheCoordinator, IAsyncDisposable
{
private const string CacheInvalidationChannel = "jellyfin_cache_invalidation";
private readonly IPostgresNotificationListener _notificationListener;
private readonly IInstanceRegistry _instanceRegistry;
private readonly ILogger<CacheCoordinator> _logger;
private readonly ILibraryManager? _libraryManager;
private bool _disposed;
private bool _started;
/// <summary>
/// Initializes a new instance of the <see cref="CacheCoordinator"/> class.
/// </summary>
/// <param name="notificationListener">The PostgreSQL notification listener.</param>
/// <param name="instanceRegistry">The instance registry.</param>
/// <param name="logger">The logger.</param>
/// <param name="libraryManager">The library manager (optional).</param>
public CacheCoordinator(
IPostgresNotificationListener notificationListener,
IInstanceRegistry instanceRegistry,
ILogger<CacheCoordinator> logger,
ILibraryManager? libraryManager = null)
{
_notificationListener = notificationListener;
_instanceRegistry = instanceRegistry;
_logger = logger;
_libraryManager = libraryManager;
}
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_started)
{
_logger.LogWarning("Cache coordinator already started");
return;
}
try
{
_notificationListener.NotificationReceived += OnNotificationReceived;
await _notificationListener.StartListeningAsync(CacheInvalidationChannel, cancellationToken).ConfigureAwait(false);
_started = true;
_logger.LogInformation("Cache coordinator started successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start cache coordinator");
throw;
}
}
/// <inheritdoc/>
public async Task StopAsync(CancellationToken cancellationToken = default)
{
if (!_started)
{
return;
}
try
{
_notificationListener.NotificationReceived -= OnNotificationReceived;
await _notificationListener.StopListeningAsync(cancellationToken).ConfigureAwait(false);
_started = false;
_logger.LogInformation("Cache coordinator stopped");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping cache coordinator");
}
}
/// <inheritdoc/>
public async Task InvalidateItemAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Item,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateUserDataAsync(Guid userId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.UserData,
EntityId = userId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateImageAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Image,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateAllAsync(CacheInvalidationType cacheType, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = cacheType,
EntityId = null,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateLibraryAsync(Guid libraryId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Library,
EntityId = libraryId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidatePersonAsync(Guid personId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Person,
EntityId = personId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task InvalidateMetadataAsync(Guid itemId, CancellationToken cancellationToken = default)
{
var message = new CacheInvalidationMessage
{
Type = CacheInvalidationType.Metadata,
EntityId = itemId,
Timestamp = DateTime.UtcNow,
SourceInstanceId = _instanceRegistry.CurrentInstanceId
};
await SendInvalidationMessageAsync(message, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
await StopAsync(CancellationToken.None).ConfigureAwait(false);
}
private async Task SendInvalidationMessageAsync(CacheInvalidationMessage message, CancellationToken cancellationToken)
{
try
{
var json = JsonSerializer.Serialize(message);
await _notificationListener.NotifyAsync(CacheInvalidationChannel, json, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Sent cache invalidation: Type={Type}, EntityId={EntityId}",
message.Type,
message.EntityId);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to send cache invalidation message: Type={Type}, EntityId={EntityId}",
message.Type,
message.EntityId);
}
}
private void OnNotificationReceived(object? sender, PostgresNotificationEventArgs e)
{
try
{
var message = JsonSerializer.Deserialize<CacheInvalidationMessage>(e.Payload);
if (message is null)
{
_logger.LogWarning("Received invalid cache invalidation message");
return;
}
// Don't process messages from this instance
if (message.SourceInstanceId.Equals(_instanceRegistry.CurrentInstanceId))
{
return;
}
_logger.LogDebug(
"Processing cache invalidation from instance {SourceInstanceId}: Type={Type}, EntityId={EntityId}",
message.SourceInstanceId,
message.Type,
message.EntityId);
ProcessInvalidationMessage(message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing cache invalidation notification");
}
}
private void ProcessInvalidationMessage(CacheInvalidationMessage message)
{
// TODO: Actual cache invalidation logic would go here
// This is a placeholder that demonstrates the pattern
// The actual implementation would call into Jellyfin's cache managers
switch (message.Type)
{
case CacheInvalidationType.Item:
if (message.EntityId.HasValue && _libraryManager is not null)
{
// Example: _libraryManager.InvalidateItemCache(message.EntityId.Value);
_logger.LogDebug("Invalidating item cache for {ItemId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.UserData:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating user data cache for {UserId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.Image:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating image cache for {ItemId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.Library:
if (message.EntityId.HasValue)
{
_logger.LogDebug("Invalidating library cache for {LibraryId}", message.EntityId.Value);
}
break;
case CacheInvalidationType.All:
_logger.LogDebug("Invalidating all caches of type {CacheType}", message.Type);
break;
default:
_logger.LogWarning("Unknown cache invalidation type: {Type}", message.Type);
break;
}
}
}
}