feat(session): share the session directory between instances
Publish every session to valkey with the instance holding it, and route a command for a non-local session to its owner over a per-instance pub/sub channel. Entries expire, so a dead instance leaves the directory.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Point-to-point delivery between instances: every instance listens on a channel of its own, so a
|
||||
/// message can be addressed to the one instance holding a given connection.
|
||||
/// </summary>
|
||||
public interface IPodMessageBus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the identity of this instance.
|
||||
/// </summary>
|
||||
string PodId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message to one instance. Delivery is best effort and never throws.
|
||||
/// </summary>
|
||||
/// <param name="targetPod">The instance to deliver to.</param>
|
||||
/// <param name="message">The message.</param>
|
||||
void Publish(string targetPod, PodMessage message);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a handler for the messages addressed to this instance.
|
||||
/// </summary>
|
||||
/// <param name="handler">The handler.</param>
|
||||
void Subscribe(Func<PodMessage, Task> handler);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The shared record of which instance holds which session. Entries expire, so an instance that stops
|
||||
/// refreshing them drops out of every other instance's view instead of lingering.
|
||||
/// </summary>
|
||||
public interface ISessionDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes an entry and restarts its expiry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task PublishAsync(SessionDirectoryEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an entry.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets every entry that has not expired.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The entries.</returns>
|
||||
Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -306,8 +306,9 @@ namespace MediaBrowser.Controller.Session
|
||||
/// <param name="activeWithinSeconds">Active within session limit.</param>
|
||||
/// <param name="controllableUserToCheck">Filter for sessions remote controllable for this user.</param>
|
||||
/// <param name="isApiKey">Is the request authenticated with API key.</param>
|
||||
/// <returns>IReadOnlyList{SessionInfoDto}.</returns>
|
||||
IReadOnlyList<SessionInfoDto> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey);
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>IReadOnlyList{SessionInfoDto}, including the sessions held by the other instances.</returns>
|
||||
Task<IReadOnlyList<SessionInfoDto>> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session by authentication token.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The single-instance <see cref="IPodMessageBus"/>: there is no other instance to reach.
|
||||
/// </summary>
|
||||
public sealed class NullPodMessageBus : IPodMessageBus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the shared instance.
|
||||
/// </summary>
|
||||
public static NullPodMessageBus Instance { get; } = new NullPodMessageBus();
|
||||
|
||||
/// <inheritdoc />
|
||||
public string PodId => PodIdentity.Current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(string targetPod, PodMessage message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(Func<PodMessage, Task> handler)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The single-instance <see cref="ISessionDirectory"/>: nothing is published and no session is held
|
||||
/// anywhere but here, which is exactly the behaviour of a deployment without a shared store.
|
||||
/// </summary>
|
||||
public sealed class NullSessionDirectory : ISessionDirectory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the shared instance.
|
||||
/// </summary>
|
||||
public static NullSessionDirectory Instance { get; } = new NullSessionDirectory();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PublishAsync(SessionDirectoryEntry entry, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<SessionDirectoryEntry>>(Array.Empty<SessionDirectoryEntry>());
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The identity of this instance among the replicas sharing a deployment.
|
||||
/// </summary>
|
||||
public static class PodIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the identity of this instance.
|
||||
/// </summary>
|
||||
public static string Current => Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// An envelope addressed to one instance. <see cref="Kind"/> names the payload so that features other
|
||||
/// than session routing can share the same channel.
|
||||
/// </summary>
|
||||
public sealed class PodMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the payload discriminator.
|
||||
/// </summary>
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the identity of the sending instance.
|
||||
/// </summary>
|
||||
public string OriginPod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized payload.
|
||||
/// </summary>
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using MediaBrowser.Model.Session;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A websocket message for a session held by another instance, carried as a <see cref="PodMessage"/>.
|
||||
/// </summary>
|
||||
public sealed class RoutedSessionMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="PodMessage.Kind"/> this payload travels under.
|
||||
/// </summary>
|
||||
public const string Kind = "SessionMessage";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the session the message is addressed to.
|
||||
/// </summary>
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message type.
|
||||
/// </summary>
|
||||
public SessionMessageType MessageType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message identifier.
|
||||
/// </summary>
|
||||
public Guid MessageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message data, serialized as JSON.
|
||||
/// </summary>
|
||||
public string Data { get; set; } = "null";
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediaBrowser.Model.Dto;
|
||||
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A session held by one instance, as the other instances see it.
|
||||
/// </summary>
|
||||
public sealed class SessionDirectoryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identity of the instance holding the connection.
|
||||
/// </summary>
|
||||
public string OwnerPod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the session as its owner last rendered it.
|
||||
/// </summary>
|
||||
public SessionInfoDto? Session { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MediaBrowser.Controller.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the session directory and the cross-instance bus that goes with it.
|
||||
/// </summary>
|
||||
public sealed class SessionDirectoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section these options bind from.
|
||||
/// </summary>
|
||||
public const string ConfigurationSection = "Jellyfin:SessionDirectory";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how long in seconds a published entry survives without being refreshed. An instance
|
||||
/// that dies stops refreshing, so its sessions leave the directory after this long.
|
||||
/// </summary>
|
||||
public int EntryTtlSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how often in seconds an instance republishes the sessions it holds.
|
||||
/// </summary>
|
||||
public int RefreshIntervalSeconds { get; set; } = 20;
|
||||
}
|
||||
Reference in New Issue
Block a user