feat(db): add opt-in PostgreSQL database provider

Multi-replica deployments cannot share a SQLite file, so the database has to
move to a server engine before the rest of the HA work is usable.

- add Jellyfin.Database.Providers.PostgreSQL with an EF Core Npgsql provider
- register the provider and a pooled NpgsqlDataSource when DatabaseType is Jellyfin-PostgreSQL
- accept postgresql:// URIs and POSTGRES_CONNECTION_STRING alongside CustomProviderOptions
- add container-backed provider, CRUD, concurrency and migration tests
This commit is contained in:
2026-09-11 23:56:04 +10:00
parent 6c073e19dd
commit e68455f0ed
15 changed files with 5644 additions and 0 deletions
+4
View File
@@ -46,6 +46,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
@@ -53,6 +54,8 @@
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
@@ -80,6 +83,7 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
@@ -6,12 +6,14 @@ using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
namespace Jellyfin.Server.Implementations.Extensions;
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
private static IEnumerable<Type> DatabaseProviderTypes()
{
yield return typeof(SqliteDatabaseProvider);
yield return typeof(PostgreSqlDatabaseProvider);
}
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
{
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
}
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
{
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
var options = config.CustomProviderOptions?.Options;
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? options
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
?.Value
?? config.CustomProviderOptions?.ConnectionString
?? throw new InvalidOperationException(
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(connectionString);
var userInfoParts = uri.UserInfo.Split(':', 2);
connectionString = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port > 0 ? uri.Port : 5432,
Database = uri.AbsolutePath.TrimStart('/'),
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
}.ToString();
}
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
return dataSourceBuilder.Build();
});
}
switch (efCoreConfiguration.LockingBehavior)
{
case DatabaseLockingBehaviorTypes.NoLock:
@@ -35,6 +35,7 @@
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
</ItemGroup>
+5
View File
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -304,6 +305,10 @@ namespace Jellyfin.Server
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
.AddSingleton<IApplicationPaths>(appPaths)
.AddSingleton<ServerApplicationPaths>(appPaths)
// Required by the NpgsqlDataSource factory in AddJellyfinDbContext when
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
// to read CustomProviderOptions and pool settings.
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
.RegisterStartupLogger();
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
+14
View File
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
@@ -95,6 +97,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.PostgreSQL", "src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj", "{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
@@ -219,6 +223,10 @@ Global
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -259,6 +267,10 @@ Global
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -290,6 +302,7 @@ Global
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
@@ -301,6 +314,7 @@ Global
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
<ProjectReference Include="..\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// Configures Jellyfin to use a PostgreSQL database.
/// </summary>
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
public sealed class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
{
// Sentinel returned by MigrationBackupFast to signal that no file backup was
// created (PostgreSQL backups are handled externally by jellyfin-pg-backup CronJob).
private const string NoAutomatedBackupKey = "postgresql-no-automated-backup";
private readonly NpgsqlDataSource _dataSource;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlDatabaseProvider"/> class.
/// </summary>
/// <param name="dataSource">The <see cref="NpgsqlDataSource"/> used for PostgreSQL connections.</param>
public PostgreSqlDatabaseProvider(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
/// <inheritdoc/>
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
/// <inheritdoc/>
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
{
options.UseNpgsql(
_dataSource,
o => o.MigrationsAssembly(GetType().Assembly.FullName));
}
/// <inheritdoc/>
public void OnModelCreating(ModelBuilder modelBuilder)
{
}
/// <inheritdoc/>
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public Task RunShutdownTask(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
{
// PostgreSQL pre-migration backups are handled externally by the
// jellyfin-pg-backup CronJob. Return a sentinel so callers know no
// file backup was created and the migration can proceed safely.
return Task.FromResult(NoAutomatedBackupKey);
}
/// <inheritdoc/>
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
{
// No automated backup was taken; nothing to restore.
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task DeleteBackup(string key)
{
// No automated backup was taken; nothing to delete.
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{
ArgumentNullException.ThrowIfNull(tableNames);
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'replica'").ConfigureAwait(false);
try
{
foreach (var tableName in tableNames)
{
var truncateSql = "TRUNCATE TABLE \"" + tableName + "\" CASCADE";
await dbContext.Database.ExecuteSqlRawAsync(truncateSql).ConfigureAwait(false);
}
}
finally
{
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'origin'").ConfigureAwait(false);
}
}
}
@@ -0,0 +1,42 @@
using System;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// The design time factory for <see cref="JellyfinDbContext"/> using PostgreSQL.
/// This is only used for the creation of migrations and not during runtime.
/// </summary>
internal sealed class PostgreSqlDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext>
{
/// <inheritdoc/>
public JellyfinDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
// Build a NpgsqlDataSource for EF Core configuration. The DI-owned singleton data source
// is not available in design-time context; this instance is intentionally not disposed here
// because EF Core holds a reference to it for the lifetime of the returned context.
// As a design-time-only factory (used only for dotnet-ef CLI operations), the process
// exits after the migration is applied, which releases all resources.
#pragma warning disable CA2000 // Dispose objects before losing scope
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
#pragma warning restore CA2000 // Dispose objects before losing scope
optionsBuilder.UseNpgsql(dataSource, o => o.MigrationsAssembly(GetType().Assembly));
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
new PostgreSqlDatabaseProvider(dataSource),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" />
</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" />
</ItemGroup>
</Project>
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
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 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.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
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.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).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);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies that concurrent inserts on <see cref="ActivityLog"/> from four parallel tasks succeed without deadlock.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
{
const int parallelTasks = 4;
const int insertsPerTask = 10;
var tasks = new List<Task>(parallelTasks);
for (var i = 0; i < parallelTasks; i++)
{
var taskIndex = i;
tasks.Add(Task.Run(
() => InsertBatchAsync(taskIndex, insertsPerTask),
TestContext.Current.CancellationToken));
}
await Task.WhenAll(tasks);
// Verify all rows were inserted
var verifyCtx = CreateContext();
await using (verifyCtx)
{
var count = await verifyCtx.ActivityLogs
.CountAsync(l => l.Type == "ConcurrencyTest", TestContext.Current.CancellationToken);
Assert.Equal(parallelTasks * insertsPerTask, count);
}
}
private async Task InsertBatchAsync(int taskIndex, int insertsPerTask)
{
var ctx = CreateContext();
await using (ctx.ConfigureAwait(false))
{
for (var j = 0; j < insertsPerTask; j++)
{
ctx.ActivityLogs.Add(new ActivityLog(
$"Task {taskIndex} Insert {j}",
"ConcurrencyTest",
Guid.Empty));
}
await ctx.SaveChangesAsync().ConfigureAwait(false);
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,98 @@
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 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.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
/// <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.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
/// </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);
await using (context)
{
await context.Database.MigrateAsync(TestContext.Current.CancellationToken);
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken);
Assert.Empty(pendingMigrations);
}
}
/// <summary>
/// Verifies that no pending model changes exist for the PostgreSQL provider,
/// acting as a CI gate that fails when model changes are added without a corresponding migration.
/// </summary>
[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);
Assert.False(
context.Database.HasPendingModelChanges(),
"There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
}
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,335 @@
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 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.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlProviderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
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.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).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);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="User"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_User()
{
var ctx = CreateContext();
await using (ctx)
{
// Create
var user = new User("testuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
ctx.Users.Add(user);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var userId = user.Id;
// Read
var read = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("testuser", read.Username);
// Update
read.Username = "updateduser";
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
Assert.Equal("updateduser", updated!.Username);
// Delete
ctx.Users.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="ActivityLog"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_ActivityLog()
{
var ctx = CreateContext();
await using (ctx)
{
// Create
var log = new ActivityLog("Test activity", "TestType", Guid.Empty);
ctx.ActivityLogs.Add(log);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var logId = log.Id;
// Read
var read = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("Test activity", read.Name);
// Update
read.Overview = "Updated overview";
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
Assert.Equal("Updated overview", updated!.Overview);
// Delete
ctx.ActivityLogs.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="DisplayPreferences"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_DisplayPreferences()
{
var ctx = CreateContext();
await using (ctx)
{
var userId = Guid.NewGuid();
var itemId = Guid.NewGuid();
// Create
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
ctx.DisplayPreferences.Add(prefs);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var prefsId = prefs.Id;
// Read
var read = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("TestClient", read.Client);
// Update
read.ShowSidebar = true;
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
Assert.True(updated!.ShowSidebar);
// Delete
ctx.DisplayPreferences.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies Create/Read/Update/Delete operations on <see cref="BaseItemEntity"/>, <see cref="Chapter"/>, and <see cref="MediaStreamInfo"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Crud_BaseItem_Chapter_MediaStream()
{
var ctx = CreateContext();
await using (ctx)
{
var itemId = Guid.NewGuid();
// Create BaseItem
var item = new BaseItemEntity { Id = itemId, Type = "Movie", Name = "Test Movie" };
ctx.BaseItems.Add(item);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
// Create Chapter linked to BaseItem
var chapter = new Chapter { ItemId = itemId, Item = item, ChapterIndex = 0, StartPositionTicks = 0, Name = "Intro" };
ctx.Chapters.Add(chapter);
// Create MediaStreamInfo linked to BaseItem
var stream = new MediaStreamInfo { ItemId = itemId, Item = item, StreamIndex = 0, StreamType = MediaStreamTypeEntity.Video };
ctx.MediaStreamInfos.Add(stream);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
// Read
var readItem = await ctx.BaseItems
.Include(i => i.Chapters)
.Include(i => i.MediaStreams)
.FirstOrDefaultAsync(i => i.Id.Equals(itemId), TestContext.Current.CancellationToken);
Assert.NotNull(readItem);
Assert.Equal("Test Movie", readItem.Name);
Assert.Single(readItem.Chapters!);
Assert.Single(readItem.MediaStreams!);
// Update
readItem.Name = "Updated Movie";
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
Assert.Equal("Updated Movie", updated!.Name);
// Delete (cascades to Chapter and MediaStreamInfo)
ctx.BaseItems.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
Assert.Null(deleted);
}
}
/// <summary>
/// Verifies that <see cref="PostgreSqlDatabaseProvider.RunScheduledOptimisation"/> executes ANALYZE without error.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RunScheduledOptimisation_ExecutesWithoutError()
{
var ctx = CreateContext();
await using (ctx)
{
var factory = new TestDbContextFactory(ctx);
_provider!.DbContextFactory = factory;
await _provider.RunScheduledOptimisation(CancellationToken.None);
}
}
/// <summary>
/// Verifies that <see cref="PostgreSqlDatabaseProvider.PurgeDatabase"/> empties tables and resets <c>session_replication_role</c>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PurgeDatabase_EmptiesTablesAndResetsFkRole()
{
var ctx = CreateContext();
await using (ctx)
{
// Seed a row
ctx.ActivityLogs.Add(new ActivityLog("Purge test", "TestType", Guid.Empty));
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
Assert.True(await ctx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
// Purge
await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
// session_replication_role should be reset to 'origin' (default)
var role = await ctx.Database
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
.FirstAsync(TestContext.Current.CancellationToken);
Assert.Equal("origin", role);
}
// Verify table is empty via a fresh context
var freshCtx = CreateContext();
await using (freshCtx)
{
Assert.False(await freshCtx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
/// <summary>
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
/// </summary>
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly JellyfinDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
/// </summary>
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
public TestDbContextFactory(JellyfinDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
/// </summary>
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public JellyfinDbContext CreateDbContext() => _context;
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
/// </summary>
/// <param name="cancellationToken">A cancellation token (unused).</param>
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_context);
}
}