Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a5fbce9b | |||
| 6c7f76fd26 | |||
| 624d528d28 | |||
| a99ca458bf |
+3
-1
@@ -59,7 +59,9 @@ steps:
|
|||||||
JELLYFIN_TEST_REDIS: "127.0.0.1:6379"
|
JELLYFIN_TEST_REDIS: "127.0.0.1:6379"
|
||||||
commands:
|
commands:
|
||||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql valkey-server
|
# libfontconfig1 is needed here too: the startup tests build a real app host, which probes
|
||||||
|
# the Skia encoder, and loading libSkiaSharp pulls fontconfig in.
|
||||||
|
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql valkey-server libfontconfig1
|
||||||
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
||||||
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
||||||
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ using Jellyfin.Server.Implementations.SystemBackupService;
|
|||||||
using MediaBrowser.Common;
|
using MediaBrowser.Common;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Events;
|
using MediaBrowser.Common.Events;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Common.Plugins;
|
using MediaBrowser.Common.Plugins;
|
||||||
using MediaBrowser.Common.Updates;
|
using MediaBrowser.Common.Updates;
|
||||||
@@ -124,6 +125,21 @@ namespace Emby.Server.Implementations
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class ApplicationHost : IServerApplicationHost, IDisposable
|
public abstract class ApplicationHost : IServerApplicationHost, IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The secret the startup read of the quick connect store looks for. No flow ever mints it, so the
|
||||||
|
/// read is always a miss and only its reachability is being asked about.
|
||||||
|
/// </summary>
|
||||||
|
private const string StartupProbeSecret = "startup-probe";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long the startup read of the quick connect store is retried before the store counts as
|
||||||
|
/// unreachable. Long enough to ride out valkey restarting alongside this instance, short enough
|
||||||
|
/// that a store which is really gone is reported inside one liveness cycle.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan _quickConnectProbeDeadline = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
private static readonly TimeSpan _quickConnectProbeRetryDelay = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The disposable parts.
|
/// The disposable parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -644,6 +660,8 @@ namespace Emby.Server.Implementations
|
|||||||
/// <returns>A task representing the service initialization operation.</returns>
|
/// <returns>A task representing the service initialization operation.</returns>
|
||||||
public async Task InitializeServices(IConfiguration startupConfig)
|
public async Task InitializeServices(IConfiguration startupConfig)
|
||||||
{
|
{
|
||||||
|
await ProbeQuickConnectStoreAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>();
|
var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>();
|
||||||
await localizationManager.LoadAll().ConfigureAwait(false);
|
await localizationManager.LoadAll().ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -652,6 +670,66 @@ namespace Emby.Server.Implementations
|
|||||||
FindParts();
|
FindParts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and reads the quick connect store here so a store that cannot be reached stops startup,
|
||||||
|
/// rather than being discovered on the first request that needs it. Both halves sit inside the
|
||||||
|
/// retry: a store built with <c>abortConnect=false</c> constructs without touching the network and
|
||||||
|
/// only the read settles it, while one built without that option connects eagerly and fails at the
|
||||||
|
/// resolve. Retried until <see cref="_quickConnectProbeDeadline"/> so a starting instance rides out
|
||||||
|
/// the blip a running one already tolerates.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProbeQuickConnectStoreAsync()
|
||||||
|
{
|
||||||
|
var startTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
var reported = false;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A throwing singleton factory is not cached, so the resolve is retried along with the read.
|
||||||
|
var store = Resolve<IQuickConnectStore>();
|
||||||
|
await store.GetRequestBySecretAsync(StartupProbeSecret).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var elapsed = Stopwatch.GetElapsedTime(startTimestamp);
|
||||||
|
if (elapsed + _quickConnectProbeRetryDelay < _quickConnectProbeDeadline)
|
||||||
|
{
|
||||||
|
if (!reported)
|
||||||
|
{
|
||||||
|
reported = true;
|
||||||
|
Logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"Quick connect store is not reachable yet, retrying for up to {Seconds}s.",
|
||||||
|
(int)_quickConnectProbeDeadline.TotalSeconds);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogDebug(ex, "Quick connect store is still not reachable, retrying.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_quickConnectProbeRetryDelay).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.LogCritical(
|
||||||
|
ex,
|
||||||
|
"Quick connect is configured against the shared valkey/Redis store at {Key} and it is UNREACHABLE after {Seconds}s, so the server will not start. Bring valkey up, or clear that setting to keep quick connect state on this instance alone.",
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey,
|
||||||
|
(int)elapsed.TotalSeconds);
|
||||||
|
|
||||||
|
if (ex is ServiceUnavailableException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ServiceUnavailableException("Quick connect store is unreachable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private X509Certificate2 GetCertificate(string path, string password)
|
private X509Certificate2 GetCertificate(string path, string password)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ using StackExchange.Redis;
|
|||||||
namespace Emby.Server.Implementations.MediaEncoding;
|
namespace Emby.Server.Implementations.MediaEncoding;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pings the configured Redis transcode session store once at startup so an unreachable store is
|
/// Reports the round trip to the configured Redis transcode session store once at startup, so the
|
||||||
/// reported there instead of being discovered as a silent loss of cross-pod takeover.
|
/// state of cross-pod takeover is visible where the server is started.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This reports, it does not gate. <see cref="ApplicationHost.InitializeServices"/> has already read the
|
||||||
|
/// same connection for quick connect by the time this runs and has stopped startup if it could not be
|
||||||
|
/// reached, so the error branch here only covers a store that went away in between.
|
||||||
|
/// </remarks>
|
||||||
public sealed class TranscodeStoreConnectivityProbe : IHostedService
|
public sealed class TranscodeStoreConnectivityProbe : IHostedService
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
@@ -34,7 +39,7 @@ public sealed class TranscodeStoreConnectivityProbe : IHostedService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Resolved here rather than injected: connecting must not be able to abort startup.
|
// Resolved here rather than injected so a store lost after the quick connect gate is reported.
|
||||||
var redis = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();
|
var redis = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false);
|
var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ namespace Emby.Server.Implementations.ScheduledTasks;
|
|||||||
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
|
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
|
||||||
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
|
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This fails open on an unreachable Redis while quick connect's startup read fails closed on the same
|
||||||
|
/// connection. They do not compete: the startup read decides whether the instance runs at all, and this
|
||||||
|
/// only decides what a running instance does about a store that went away afterwards.
|
||||||
|
/// </remarks>
|
||||||
public sealed class RedisScanLeaderLease : IScanLeaderLease
|
public sealed class RedisScanLeaderLease : IScanLeaderLease
|
||||||
{
|
{
|
||||||
private const string LeaderKey = "jellyfin:scanleader";
|
private const string LeaderKey = "jellyfin:scanleader";
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ public static class QuickConnectStoreServiceCollectionExtensions
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The connection string is only set for a multi-instance deployment, which is the only shape where
|
/// The connection string is only set for a multi-instance deployment, which is the only shape where
|
||||||
/// the initiate, authorize and exchange legs of one flow can land on different instances. Set but
|
/// the initiate, authorize and exchange legs of one flow can land on different instances. Set but
|
||||||
/// unreachable is a misconfigured deployment rather than a single-instance one, so it fails rather
|
/// unreachable is a misconfigured deployment rather than a single-instance one, so the store is read
|
||||||
/// than quietly handing out a store the other instances cannot see.
|
/// once during startup and an unreachable one stops the server coming up, rather than quietly handing
|
||||||
|
/// out a store the other instances cannot see.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="serviceCollection">The service collection.</param>
|
/// <param name="serviceCollection">The service collection.</param>
|
||||||
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
|
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ public static class TranscodeStoreServiceCollectionExtensions
|
|||||||
/// Registers the transcode session store, Redis-backed when a connection string is configured and
|
/// Registers the transcode session store, Redis-backed when a connection string is configured and
|
||||||
/// no-op otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
|
/// no-op otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <see cref="IConnectionMultiplexer"/> registered here is the one connection every Redis-backed
|
||||||
|
/// component shares, so configuring it makes valkey a hard startup dependency: quick connect reads it
|
||||||
|
/// during <c>InitializeServices</c> and stops the server when it cannot be reached. The softer
|
||||||
|
/// policies elsewhere - this store's connectivity report, the scan leader's fail-open - are
|
||||||
|
/// subordinate to that and only govern a store that goes away after that read has passed.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="serviceCollection">The service collection.</param>
|
/// <param name="serviceCollection">The service collection.</param>
|
||||||
/// <param name="configuration">The configuration to read <c>Jellyfin:TranscodeStore</c> from.</param>
|
/// <param name="configuration">The configuration to read <c>Jellyfin:TranscodeStore</c> from.</param>
|
||||||
/// <param name="logger">The logger to report the selected store on.</param>
|
/// <param name="logger">The logger to report the selected store on.</param>
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ namespace Jellyfin.Server
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LoggingConfigFileSystem = "logging.json";
|
public const string LoggingConfigFileSystem = "logging.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long a failed start keeps the setup server answering before the process exits, so an
|
||||||
|
/// install without a supervisor shows the failure instead of spinning. Skipped under an
|
||||||
|
/// orchestrator, where restarting is its job and holding only stretches the crash loop.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan _setupServerHoldAfterFailedStart = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory();
|
private static readonly SerilogLoggerFactory _loggerFactory = new SerilogLoggerFactory();
|
||||||
private static SetupServer? _setupServer;
|
private static SetupServer? _setupServer;
|
||||||
private static CoreAppHost? _appHost;
|
private static CoreAppHost? _appHost;
|
||||||
@@ -255,13 +262,14 @@ namespace Jellyfin.Server
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_restartOnShutdown = false;
|
_restartOnShutdown = false;
|
||||||
|
Environment.ExitCode = 1;
|
||||||
_logger.LogCritical(ex, "Error while starting server");
|
_logger.LogCritical(ex, "Error while starting server");
|
||||||
if (_setupServer!.IsAlive && !configurationCompleted)
|
if (_setupServer!.IsAlive && !configurationCompleted)
|
||||||
{
|
{
|
||||||
_setupServer!.SoftStop();
|
_setupServer!.SoftStop();
|
||||||
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
|
if ((options.StartupMode is null or Configuration.StartupMode.MediaServer) && !IsRunningInContainer())
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
|
await Task.Delay(_setupServerHoldAfterFailedStart).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _setupServer!.StopAsync().ConfigureAwait(false);
|
await _setupServer!.StopAsync().ConfigureAwait(false);
|
||||||
@@ -285,6 +293,10 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set by every official .NET base image, including the one this server ships in.
|
||||||
|
private static bool IsRunningInContainer()
|
||||||
|
=> string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// [Internal]Runs the startup Migrations.
|
/// [Internal]Runs the startup Migrations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -83,6 +83,29 @@ Scan-leader gating is off: timer-driven library tasks run on every instance.
|
|||||||
|
|
||||||
`Enabled=true` with no Redis connection string logs a warning, because gating cannot run.
|
`Enabled=true` with no Redis connection string logs a warning, because gating cannot run.
|
||||||
|
|
||||||
|
### Valkey is a hard startup dependency
|
||||||
|
|
||||||
|
`Jellyfin:TranscodeStore:RedisConnectionString` selects one shared `IConnectionMultiplexer` and every
|
||||||
|
Redis-backed component hangs off it. Three of them disagree about an unreachable store on purpose, and
|
||||||
|
the order they run in is what makes that coherent:
|
||||||
|
|
||||||
|
| Component | On an unreachable store | When |
|
||||||
|
|---|---|---|
|
||||||
|
| Quick connect store (`ApplicationHost.ProbeQuickConnectStoreAsync`) | **Fails closed.** Reads a sentinel secret, retried for 30s, then logs `Critical` and stops startup | `InitializeServices`, before anything is served |
|
||||||
|
| `TranscodeStoreConnectivityProbe` | Logs `Error` and carries on | `IHostedService` start, after the gate |
|
||||||
|
| `RedisScanLeaderLease` | Fails open, treats itself as leader | Per scheduled-task tick, long after the gate |
|
||||||
|
|
||||||
|
The gate wins because it runs first: with a connection string set, an instance that reaches
|
||||||
|
`IHostedService` start has already proved the store reachable. The softer policies govern only a store
|
||||||
|
that goes away *afterwards*, where a running instance degrades rather than dying — quick connect calls
|
||||||
|
return `503`, transcode takeover stops, every instance scans.
|
||||||
|
|
||||||
|
The 30s window is there so a rollout survives valkey restarting alongside the server. Past it the
|
||||||
|
deployment is misconfigured or broken, the process exits non-zero and the orchestrator reports the real
|
||||||
|
cause. Upstream's 10-minute hold, which keeps the setup server answering after *any* failed start, is
|
||||||
|
skipped when `DOTNET_RUNNING_IN_CONTAINER` is set, because there the restart is the supervisor's job and
|
||||||
|
holding only stretches the crash loop.
|
||||||
|
|
||||||
### PostgreSQL provider
|
### PostgreSQL provider
|
||||||
|
|
||||||
`src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` is an EF Core
|
`src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` is an EF Core
|
||||||
|
|||||||
+4
-3
@@ -11,9 +11,10 @@ using Xunit;
|
|||||||
namespace Jellyfin.Server.Tests.HighAvailability;
|
namespace Jellyfin.Server.Tests.HighAvailability;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A Redis store that cannot be reached degrades silently: the client is configured not to abort the
|
/// A transcode store that cannot be reached degrades silently: the client is configured not to abort
|
||||||
/// connection and every call site swallows failures. The probe is the only startup signal, so both of
|
/// the connection and every call site swallows failures. Quick connect's startup read is what stops a
|
||||||
/// its outcomes are pinned here.
|
/// pod coming up against a dead store; this probe only reports it, so both of its outcomes are pinned
|
||||||
|
/// here - including that it never throws, which is what keeps the two policies from competing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TranscodeStoreConnectivityProbeTests
|
public sealed class TranscodeStoreConnectivityProbeTests
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
using Emby.Server.Implementations;
|
||||||
|
using Emby.Server.Implementations.QuickConnect;
|
||||||
|
using Jellyfin.Server.Extensions;
|
||||||
|
using Jellyfin.Server.Helpers;
|
||||||
|
using Jellyfin.Server.Migrations.Stages;
|
||||||
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
|
using Jellyfin.Server.Tests.HighAvailability;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Common.Net;
|
||||||
|
using MediaBrowser.Controller.Net;
|
||||||
|
using MediaBrowser.Controller.QuickConnect;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Tests.QuickConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings the server up the way <c>Program</c> does - the real host over <see cref="Startup"/>, the
|
||||||
|
/// startup and core migrations, then <see cref="ApplicationHost.InitializeServices"/> - to pin down what
|
||||||
|
/// a pod does when the quick connect store it is configured against cannot be reached.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "RequiresDocker")]
|
||||||
|
[Collection("JellyfinSectionConfiguration")]
|
||||||
|
public sealed class QuickConnectStartupTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString";
|
||||||
|
private const string FfmpegNoValidationVariable = "JELLYFIN_FFMPEG__NOVALIDATION";
|
||||||
|
private const string DeadStore = "127.0.0.1:1,abortConnect=false,connectTimeout=250,connectRetry=0,syncTimeout=250";
|
||||||
|
private const string EagerDeadStore = "127.0.0.1:1,connectTimeout=250,connectRetry=0,syncTimeout=250";
|
||||||
|
|
||||||
|
private RedisTestServer _redis = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(FfmpegNoValidationVariable, "true");
|
||||||
|
_redis = await RedisTestServer.StartAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
|
||||||
|
Environment.SetEnvironmentVariable(FfmpegNoValidationVariable, null);
|
||||||
|
await _redis.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A store that stays away past the probe's deadline stops the server coming up, so the outage is
|
||||||
|
/// visible where the server is started instead of arriving later as a failure on every request that
|
||||||
|
/// needs the store. The connection string carries the <c>abortConnect=false</c> a deployment uses, so
|
||||||
|
/// the multiplexer connects lazily and only a real read settles whether the store can be served.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void StoreDownPastTheDeadline_StopsStartup()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, DeadStore);
|
||||||
|
|
||||||
|
using var server = new StartupHarness();
|
||||||
|
|
||||||
|
Assert.ThrowsAny<ServiceUnavailableException>(() => server.Services);
|
||||||
|
Assert.Contains(
|
||||||
|
server.CriticalEntries,
|
||||||
|
entry => entry.Contains("Quick connect", StringComparison.Ordinal)
|
||||||
|
&& entry.Contains("UNREACHABLE", StringComparison.Ordinal)
|
||||||
|
&& entry.Contains("valkey", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Three of the four connection strings the chart documents leave <c>abortConnect</c> at its default,
|
||||||
|
/// which connects eagerly, so the multiplexer is what fails and it fails while the store is being
|
||||||
|
/// built rather than on a read. The probe has to retry the build as well as the read and end on the
|
||||||
|
/// same message, not let a bare <see cref="RedisConnectionException"/> out.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The core initialisation migrations are skipped here because <c>JellyfinMigrationService</c> takes
|
||||||
|
/// an <c>IBackupService</c> eagerly, which reaches the multiplexer through the library manager, so on
|
||||||
|
/// this shape they fail before the probe is reached at all. That ordering is a separate problem from
|
||||||
|
/// what the probe does when it runs.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void EagerlyConnectingStoreDownPastTheDeadline_StopsStartupWithTheSameMessage()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, EagerDeadStore);
|
||||||
|
|
||||||
|
using var server = new StartupHarness(runCoreInitialisationMigrations: false);
|
||||||
|
|
||||||
|
Assert.ThrowsAny<ServiceUnavailableException>(() => server.Services);
|
||||||
|
Assert.Contains(
|
||||||
|
server.CriticalEntries,
|
||||||
|
entry => entry.Contains("Quick connect", StringComparison.Ordinal)
|
||||||
|
&& entry.Contains("UNREACHABLE", StringComparison.Ordinal)
|
||||||
|
&& entry.Contains("valkey", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A store that is away when the probe first reads it but back inside the deadline lets the server
|
||||||
|
/// come up. A running instance already rides out a valkey blip; a starting one has to as well, or a
|
||||||
|
/// rollout is hostage to valkey restarting at the same time.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreBackInsideTheDeadline_StartsAnyway()
|
||||||
|
{
|
||||||
|
await using var proxy = RedisFaultProxy.Start(_redis.ConnectionString);
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, proxy.ConnectionString);
|
||||||
|
|
||||||
|
using var server = new StartupHarness(() =>
|
||||||
|
{
|
||||||
|
proxy.Cut();
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
|
||||||
|
proxy.Restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.IsType<RedisQuickConnectStore>(server.Services.GetRequiredService<IQuickConnectStore>());
|
||||||
|
Assert.Contains(server.WarningEntries, entry => entry.Contains("not reachable yet", StringComparison.Ordinal));
|
||||||
|
Assert.Empty(server.CriticalEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A start that failed has to look like a failure to whatever supervises the process. The server runs
|
||||||
|
/// as its own process here because the exit code is not observable anywhere else, and in a container
|
||||||
|
/// it must not sit on the setup server for ten minutes before getting there.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task StoreDownPastTheDeadline_ExitsNonZero()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "jellyfin-quickconnect-exit", Path.GetRandomFileName());
|
||||||
|
var configDirectory = Path.Combine(root, "config");
|
||||||
|
Directory.CreateDirectory(configDirectory);
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "cache"));
|
||||||
|
WriteNetworkConfiguration(configDirectory);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = AppContext.BaseDirectory
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var argument in new[]
|
||||||
|
{
|
||||||
|
"exec",
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "jellyfin.dll"),
|
||||||
|
"--datadir", root,
|
||||||
|
"--cachedir", Path.Combine(root, "cache"),
|
||||||
|
"--nowebclient"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.Environment[RedisConnectionStringVariable] = DeadStore;
|
||||||
|
startInfo.Environment[FfmpegNoValidationVariable] = "true";
|
||||||
|
startInfo.Environment["DOTNET_RUNNING_IN_CONTAINER"] = "true";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (exitCode, output) = await RunToCompletionAsync(startInfo, TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
Assert.Contains("UNREACHABLE", output, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(1, exitCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reachable store lets the server come up, and the quick connect it comes up with holds its
|
||||||
|
/// requests in the shared store every instance reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReachableStore_StartsAndServesQuickConnect()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, _redis.ConnectionString);
|
||||||
|
|
||||||
|
using var server = new StartupHarness();
|
||||||
|
|
||||||
|
Assert.IsType<RedisQuickConnectStore>(server.Services.GetRequiredService<IQuickConnectStore>());
|
||||||
|
|
||||||
|
var quickConnect = server.Services.GetRequiredService<IQuickConnect>();
|
||||||
|
var request = await quickConnect.TryConnect(NewAuthorizationInfo());
|
||||||
|
|
||||||
|
Assert.Equal(request.Code, (await quickConnect.CheckRequestStatus(request.Secret)).Code);
|
||||||
|
|
||||||
|
await using var redis = await _redis.ConnectAsync();
|
||||||
|
Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Without a connection string the deployment is single-instance, and it starts on the process-local
|
||||||
|
/// store upstream uses.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task NoConnectionString_StartsOnTheProcessLocalStore()
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
|
||||||
|
|
||||||
|
using var server = new StartupHarness();
|
||||||
|
|
||||||
|
Assert.IsType<InMemoryQuickConnectStore>(server.Services.GetRequiredService<IQuickConnectStore>());
|
||||||
|
|
||||||
|
var quickConnect = server.Services.GetRequiredService<IQuickConnect>();
|
||||||
|
var request = await quickConnect.TryConnect(NewAuthorizationInfo());
|
||||||
|
|
||||||
|
Assert.Equal(request.Code, (await quickConnect.CheckRequestStatus(request.Secret)).Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthorizationInfo NewAuthorizationInfo() => new AuthorizationInfo
|
||||||
|
{
|
||||||
|
DeviceId = Guid.NewGuid().ToString("N"),
|
||||||
|
Device = "Living Room TV",
|
||||||
|
Client = "Jellyfin Web",
|
||||||
|
Version = "1.0.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
// The setup server binds before anything else runs, so the spawned server gets a port of its own.
|
||||||
|
private static void WriteNetworkConfiguration(string configDirectory)
|
||||||
|
{
|
||||||
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
|
||||||
|
var configuration = new NetworkConfiguration
|
||||||
|
{
|
||||||
|
InternalHttpPort = port,
|
||||||
|
PublicHttpPort = port,
|
||||||
|
EnableHttps = false,
|
||||||
|
AutoDiscovery = false
|
||||||
|
};
|
||||||
|
|
||||||
|
using var writer = new StreamWriter(Path.Combine(configDirectory, "network.xml"));
|
||||||
|
new XmlSerializer(typeof(NetworkConfiguration)).Serialize(writer, configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(int ExitCode, string Output)> RunToCompletionAsync(ProcessStartInfo startInfo, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var output = new StringBuilder();
|
||||||
|
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||||
|
process.OutputDataReceived += (_, e) => Append(output, e.Data);
|
||||||
|
process.ErrorDataReceived += (_, e) => Append(output, e.Data);
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource(timeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(cancellation.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
process.Kill(true);
|
||||||
|
throw new TimeoutException($"The server did not exit within {timeout}. Output:\n{output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (process.ExitCode, output.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Append(StringBuilder output, string? line)
|
||||||
|
{
|
||||||
|
if (line is not null)
|
||||||
|
{
|
||||||
|
lock (output)
|
||||||
|
{
|
||||||
|
output.AppendLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(path, true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// A temporary directory left behind is not worth failing a test over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StartupHarness : WebApplicationFactory<Startup>
|
||||||
|
{
|
||||||
|
private readonly ConcurrentBag<IDisposable> _disposables = new();
|
||||||
|
private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries = new();
|
||||||
|
private readonly Action? _beforeInitializeServices;
|
||||||
|
private readonly bool _runCoreInitialisationMigrations;
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"jellyfin-quickconnect-startup",
|
||||||
|
Path.GetRandomFileName());
|
||||||
|
|
||||||
|
static StartupHarness()
|
||||||
|
{
|
||||||
|
StartupHelpers.PerformStaticInitialization();
|
||||||
|
}
|
||||||
|
|
||||||
|
public StartupHarness(Action? beforeInitializeServices = null, bool runCoreInitialisationMigrations = true)
|
||||||
|
{
|
||||||
|
_beforeInitializeServices = beforeInitializeServices;
|
||||||
|
_runCoreInitialisationMigrations = runCoreInitialisationMigrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> CriticalEntries => Messages(LogLevel.Critical);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> WarningEntries => Messages(LogLevel.Warning);
|
||||||
|
|
||||||
|
protected override IHostBuilder CreateHostBuilder() => new HostBuilder();
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
var commandLineOpts = new StartupOptions();
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "logs"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "config"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "cache"));
|
||||||
|
Directory.CreateDirectory(Path.Combine(_root, "jellyfin-web"));
|
||||||
|
var appPaths = new ServerApplicationPaths(
|
||||||
|
_root,
|
||||||
|
Path.Combine(_root, "logs"),
|
||||||
|
Path.Combine(_root, "config"),
|
||||||
|
Path.Combine(_root, "cache"),
|
||||||
|
Path.Combine(_root, "jellyfin-web"));
|
||||||
|
|
||||||
|
StartupHelpers.InitLoggingConfigFile(appPaths).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var startupConfig = Program.CreateAppConfiguration(commandLineOpts, appPaths);
|
||||||
|
|
||||||
|
ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging
|
||||||
|
.SetMinimumLevel(LogLevel.Warning)
|
||||||
|
.AddProvider(new RecordingProvider(_entries)));
|
||||||
|
_disposables.Add(loggerFactory);
|
||||||
|
|
||||||
|
var appHost = new CoreAppHost(appPaths, loggerFactory, commandLineOpts, startupConfig);
|
||||||
|
_disposables.Add(appHost);
|
||||||
|
|
||||||
|
builder.ConfigureServices(services => appHost.Init(services))
|
||||||
|
.ConfigureWebHostBuilder(appHost, startupConfig, appPaths, NullLogger.Instance)
|
||||||
|
.ConfigureAppConfiguration((context, configuration) => configuration
|
||||||
|
.SetBasePath(appPaths.ConfigurationDirectoryPath)
|
||||||
|
.AddInMemoryCollection(Emby.Server.Implementations.ConfigurationOptions.DefaultConfiguration)
|
||||||
|
.AddEnvironmentVariables("JELLYFIN_")
|
||||||
|
.AddInMemoryCollection(commandLineOpts.ConvertToConfig()))
|
||||||
|
.ConfigureServices(services => services.RegisterStartupLogger());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IHost CreateHost(IHostBuilder builder)
|
||||||
|
{
|
||||||
|
var host = builder.Build();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appHost = (CoreAppHost)host.Services.GetRequiredService<MediaBrowser.Common.IApplicationHost>();
|
||||||
|
appHost.ServiceProvider = host.Services;
|
||||||
|
var appPaths = (ServerApplicationPaths)host.Services.GetRequiredService<IApplicationPaths>();
|
||||||
|
var configuration = host.Services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
|
Program.ApplyStartupMigrationAsync(appPaths, configuration, new StartupOptions()).GetAwaiter().GetResult();
|
||||||
|
if (_runCoreInitialisationMigrations)
|
||||||
|
{
|
||||||
|
Program.ApplyCoreMigrationsAsync(host.Services, JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
_beforeInitializeServices?.Invoke();
|
||||||
|
appHost.InitializeServices(configuration).GetAwaiter().GetResult();
|
||||||
|
Program.ApplyCoreMigrationsAsync(host.Services, JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
|
||||||
|
host.Start();
|
||||||
|
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The factory only owns the host once this returns, so a failed start disposes it here
|
||||||
|
// or the multiplexer it holds outlives the test.
|
||||||
|
host.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
|
||||||
|
foreach (var disposable in _disposables)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposables.Clear();
|
||||||
|
|
||||||
|
TryDelete(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyCollection<string> Messages(LogLevel level)
|
||||||
|
=> _entries.Where(entry => entry.Level == level).Select(entry => entry.Message).ToArray();
|
||||||
|
|
||||||
|
private sealed class RecordingProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries;
|
||||||
|
|
||||||
|
public RecordingProvider(ConcurrentQueue<(LogLevel Level, string Message)> entries)
|
||||||
|
{
|
||||||
|
_entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName) => new RecordingLogger(_entries);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingLogger : ILogger
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<(LogLevel Level, string Message)> _entries;
|
||||||
|
|
||||||
|
public RecordingLogger(ConcurrentQueue<(LogLevel Level, string Message)> entries)
|
||||||
|
{
|
||||||
|
_entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull
|
||||||
|
=> null;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
|
||||||
|
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (IsEnabled(logLevel))
|
||||||
|
{
|
||||||
|
_entries.Enqueue((logLevel, formatter!(state, exception)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,9 +108,11 @@ public sealed class QuickConnectStoreWiringTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A connection string that is set but unreachable is a misconfigured multi-instance deployment. It
|
/// An unreachable connection string that connects eagerly, the default, throws while the store is
|
||||||
/// fails rather than handing out a store the other instances cannot see, which would put quick
|
/// being built rather than on a read. A failed singleton factory is not cached, so every resolve
|
||||||
/// connect back on the cross-instance behaviour this configuration exists to fix.
|
/// throws afresh, which is what lets the startup probe in <see cref="QuickConnectStartupTests"/>
|
||||||
|
/// retry the build and report an eager store's outage as the same operator-facing failure it reports
|
||||||
|
/// for the lazily connecting form.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnreachableRedisAtStartup_FailsClosed()
|
public void UnreachableRedisAtStartup_FailsClosed()
|
||||||
@@ -120,6 +122,7 @@ public sealed class QuickConnectStoreWiringTests : IAsyncLifetime
|
|||||||
using var provider = BuildProvider();
|
using var provider = BuildProvider();
|
||||||
|
|
||||||
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
|
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
|
||||||
|
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static QuickConnectResult NewRequest() => new QuickConnectResult(
|
private static QuickConnectResult NewRequest() => new QuickConnectResult(
|
||||||
|
|||||||
Reference in New Issue
Block a user