feat(session): share the session directory between instances
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed

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:
2026-09-24 22:48:48 +10:00
parent 6691b785c3
commit 00d0765152
24 changed files with 1203 additions and 30 deletions
@@ -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;
}