77e30685bb
- 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.
310 lines
11 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|