Compare commits

..

7 Commits

Author SHA1 Message Date
benvin 1c98f4a074 Merge pull request #31 from benvin/pg-tests-in-ci
ci/woodpecker/push/ci Pipeline was successful
test(db): run the PostgreSQL provider tests in CI
2026-09-21 07:45:29 +10:00
benvin 393994a454 Merge pull request #30 from benvin/nextup-datetime-kind
ci/woodpecker/push/ci Pipeline was canceled
fix(api): normalise the Next Up cutoff to UTC
2026-09-21 07:42:33 +10:00
benvin ad50c4e433 Merge pull request #28 from benvin/gated-task-keys
ci/woodpecker/push/ci Pipeline was canceled
fix(ha): gate the remaining timer-driven scheduled tasks
2026-09-21 07:40:32 +10:00
unkin-agent d39ec60e2c test(db): link PostgreSqlTestServer instead of referencing Jellyfin.Server.Tests
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-21 00:29:11 +10:00
unkin-agent 44b62dcc64 test(ha): pin the default gated task key set
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-20 23:55:17 +10:00
unkin-agent a025655b4d test(db): run the PostgreSQL provider tests in CI
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Point Jellyfin.Database.Tests.PostgreSQL at the server postgres-migration-chain
already runs, give every test its own database, and fix the two tests that only
ever failed against real PostgreSQL.
2026-09-20 23:48:43 +10:00
unkin-agent 1965c68a76 fix(ha): gate the remaining timer-driven scheduled tasks
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add the eight provider, live TV and plugin-update task keys to
ScanLeaderOptions.GatedTaskKeys and widen the test's task-key discovery to
every assembly that declares an IScheduledTask.
2026-09-20 23:36:34 +10:00
8 changed files with 190 additions and 84 deletions
+4 -3
View File
@@ -45,9 +45,8 @@ steps:
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
# scalar query and on its own data - which a third test in the class then inherits.
# Both projects attach to it through JELLYFIN_TEST_POSTGRES and give every test a database of
# its own, so nothing here depends on a docker daemon.
- name: postgres-migration-chain
image: mcr.microsoft.com/dotnet/sdk:10.0
depends_on:
@@ -64,7 +63,9 @@ steps:
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
- dotnet build tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
- dotnet test tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
backend_options:
kubernetes:
serviceAccountName: jellyfin-ha-src
@@ -43,6 +43,14 @@ public sealed class ScanLeaderOptions
"TaskExtractMediaSegments",
"KeyframeExtraction",
"CleanupUserDataTask",
"OptimizeDatabaseTask"
"OptimizeDatabaseTask",
"DownloadLyrics",
"DownloadSubtitles",
"TmdbRefreshUpcomingEpisodes",
"RefreshTrickplayImages",
"MoveTrickplayImages",
"RefreshInternetChannels",
"RefreshGuide",
"PluginUpdates"
};
}
@@ -18,6 +18,11 @@
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<!-- Linked, not project-referenced: Jellyfin.Server.Tests drags the whole server into this output. -->
<Compile Include="..\Jellyfin.Server.Tests\Migrations\PostgreSqlTestServer.cs" Link="Migrations\PostgreSqlTestServer.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
@@ -1,71 +1,68 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
/// Integration tests that verify concurrent access patterns against a real PostgreSQL server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
/// </summary>
public PostgreSqlConcurrencyTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// Attaches to the test server, hands this test a database of its own and applies migrations to it.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var databaseName = FormattableString.Invariant($"pg_concurrency_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
@@ -1,62 +1,68 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that validate PostgreSQL migrations against a real container.
/// Integration tests that validate PostgreSQL migrations against a real server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
/// </summary>
public PostgreSqlMigrationTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container before any tests in the class run.
/// Attaches to the test server and hands this test an empty database of its own.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
var databaseName = FormattableString.Invariant($"pg_migration_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync().ConfigureAwait(false);
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh database.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MigrateAsync_AppliesInitialMigrationCleanly()
{
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var context = CreateContext(dataSource);
var context = CreateContext(_dataSource!);
await using (context)
{
await context.Database.MigrateAsync(TestContext.Current.CancellationToken);
@@ -73,11 +79,7 @@ public sealed class PostgreSqlMigrationTests : IAsyncLifetime
[Fact]
public void CheckForUnappliedMigrations_PostgreSql()
{
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
// that compares the current compiled model with the migration snapshots — no real DB needed.
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
using var context = CreateContext(dataSource);
using var context = CreateContext(_dataSource!);
Assert.False(
context.Database.HasPendingModelChanges(),
@@ -2,71 +2,67 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlProviderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
/// </summary>
public PostgreSqlProviderTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// Attaches to the test server, hands this test a database of its own and applies migrations to it.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var databaseName = FormattableString.Invariant($"pg_provider_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
@@ -155,11 +151,15 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime
var ctx = CreateContext();
await using (ctx)
{
var userId = Guid.NewGuid();
// DisplayPreferences.UserId is a foreign key onto Users, which PostgreSQL enforces and SQLite does not.
var user = new User("prefsuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
ctx.Users.Add(user);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var itemId = Guid.NewGuid();
// Create
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
var prefs = new DisplayPreferences(user.Id, itemId, "TestClient");
ctx.DisplayPreferences.Add(prefs);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
@@ -278,7 +278,7 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime
// session_replication_role should be reset to 'origin' (default)
var role = await ctx.Database
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role') AS \"Value\"")
.FirstAsync(TestContext.Current.CancellationToken);
Assert.Equal("origin", role);
}
@@ -31,6 +31,8 @@
<ItemGroup>
<ProjectReference Include="..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
<ProjectReference Include="..\..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
<ProjectReference Include="..\..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj" />
<ProjectReference Include="..\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using MediaBrowser.Controller.ScheduledTasks;
@@ -11,6 +13,14 @@ namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
public class ScanLeaderOptionsTests
{
private static readonly Assembly[] _taskAssemblies =
{
typeof(DeleteTranscodeFileTask).Assembly,
typeof(MediaBrowser.Providers.Lyric.LyricScheduledTask).Assembly,
typeof(Jellyfin.LiveTv.Guide.RefreshGuideScheduledTask).Assembly,
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
};
/// <summary>
/// A gated key that matches no registered task silently stops gating anything, so the default
/// set is pinned to the task keys that actually exist in the build.
@@ -18,28 +28,92 @@ public class ScanLeaderOptionsTests
[Fact]
public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks()
{
var registeredKeys = DiscoverScheduledTaskKeys();
var registeredKeys = DiscoverScheduledTaskKeys(_taskAssemblies);
Assert.NotEmpty(registeredKeys);
Assert.Empty(new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal));
var unmatched = new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal).ToList();
Assert.True(
unmatched.Count == 0,
$"Gated keys match no scheduled task: {string.Join(", ", unmatched)}. Known keys: {string.Join(", ", registeredKeys.Order(StringComparer.Ordinal))}");
}
private static HashSet<string> DiscoverScheduledTaskKeys()
/// <summary>
/// A key dropped from the default set silently un-gates that task on every replica, so the whole
/// set is pinned against a hand-maintained expectation rather than read back from the options.
/// </summary>
[Fact]
public void DefaultGatedTaskKeys_Should_BeTheExpectedSet()
{
var keys = new HashSet<string>(StringComparer.Ordinal);
var assemblies = new[]
string[] expected =
{
typeof(DeleteTranscodeFileTask).Assembly,
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
"AudioNormalization",
"CleanupUserDataTask",
"DownloadLyrics",
"DownloadSubtitles",
"KeyframeExtraction",
"MoveTrickplayImages",
"OptimizeDatabaseTask",
"PluginUpdates",
"RefreshChapterImages",
"RefreshGuide",
"RefreshInternetChannels",
"RefreshLibrary",
"RefreshPeople",
"RefreshTrickplayImages",
"TaskExtractMediaSegments",
"TmdbRefreshUpcomingEpisodes"
};
foreach (var type in assemblies.SelectMany(a => a.GetTypes()))
var actual = new ScanLeaderOptions().GatedTaskKeys;
var missing = expected.Except(actual, StringComparer.Ordinal).ToList();
var unexpected = actual.Except(expected, StringComparer.Ordinal).ToList();
Assert.True(
missing.Count == 0 && unexpected.Count == 0,
$"Default gated task keys drifted. Missing: {Describe(missing)}. Unexpected: {Describe(unexpected)}.");
}
/// <summary>
/// The key universe is only as complete as the assemblies it is read from, so a task added to an
/// unscanned assembly must fail here rather than narrow what the previous test can catch.
/// </summary>
[Fact]
public void TaskAssemblies_Should_CoverEveryAssemblyDeclaringScheduledTasks()
{
var scanned = _taskAssemblies.Select(a => a.GetName().Name).ToHashSet(StringComparer.Ordinal);
var missing = new List<string>();
foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
{
if (type.IsAbstract || type.IsInterface || !typeof(IScheduledTask).IsAssignableFrom(type))
var name = Path.GetFileNameWithoutExtension(path);
if (scanned.Contains(name)
|| name.EndsWith(".Tests", StringComparison.Ordinal)
|| !(name.StartsWith("Jellyfin.", StringComparison.Ordinal)
|| name.StartsWith("Emby.", StringComparison.Ordinal)
|| name.StartsWith("MediaBrowser.", StringComparison.Ordinal)))
{
continue;
}
if (GetScheduledTaskTypes(Assembly.LoadFrom(path)).Any())
{
missing.Add(name);
}
}
Assert.True(missing.Count == 0, $"Assemblies declaring scheduled tasks but not scanned: {string.Join(", ", missing)}");
}
private static string Describe(IReadOnlyCollection<string> keys)
=> keys.Count == 0 ? "none" : string.Join(", ", keys.Order(StringComparer.Ordinal));
private static HashSet<string> DiscoverScheduledTaskKeys(IEnumerable<Assembly> assemblies)
{
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in assemblies.SelectMany(GetScheduledTaskTypes))
{
// Task keys are constant expressions, so an uninitialised instance is enough to read
// them without standing up each task's dependency graph.
var task = (IScheduledTask)RuntimeHelpers.GetUninitializedObject(type);
@@ -48,4 +122,21 @@ public class ScanLeaderOptionsTests
return keys;
}
private static IEnumerable<Type> GetScheduledTaskTypes(Assembly assembly)
{
Type?[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types;
}
return types
.Where(t => t is not null && !t.IsAbstract && !t.IsInterface && typeof(IScheduledTask).IsAssignableFrom(t))
.Select(t => t!);
}
}