Add automatic PostgreSQL database creation and migration

- Added Jellyfin.Database.Providers.Postgres project with full EF Core migration support for PostgreSQL.
- Implemented automatic database creation and privilege assignment on startup.
- Generated initial migration and model snapshot for PostgreSQL schema.
- Updated build, test, and dependency files to include PostgreSQL provider and Npgsql packages.
- Added PowerShell script for generating and testing PostgreSQL migrations.
- No changes to application logic outside database provider/migration infrastructure.
This commit is contained in:
2026-02-22 18:51:24 -05:00
parent 0efab2cfb6
commit a3eb4b1b57
52 changed files with 9233 additions and 33 deletions
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f8b7c62f9e1f477916308d6395db212ca1fca410")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+0efab2cfb69d762cf13ca5b602f93d2cccceaf5b")]
[assembly: System.Reflection.AssemblyProductAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyTitleAttribute("Jellyfin.CodeAnalysis")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
79af6817c2e15e73182d4e4594eab8c486fd3066d87d7dd560325ec905918c97
3d4d85062f927bfd4145d798a69747c46d5f35ab7d8ff4fb93a81f8cd87c4784
@@ -43,6 +43,8 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
private static ReaderWriterLockSlim DatabaseLock { get; } = new(LockRecursionPolicy.SupportsRecursion);
private static AsyncLocal<int> WriteLockDepth { get; } = new();
/// <inheritdoc/>
public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
{
@@ -83,56 +85,114 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
public override InterceptionResult<DbTransaction> TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result)
{
DbLock.BeginWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth == 0)
{
DbLock.BeginWriteLock(this._logger);
WriteLockDepth.Value = currentDepth + 1;
}
return base.TransactionStarting(connection, eventData, result);
}
public override ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result, CancellationToken cancellationToken = default)
{
DbLock.BeginWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth == 0)
{
DbLock.BeginWriteLock(this._logger);
WriteLockDepth.Value = currentDepth + 1;
}
return base.TransactionStartingAsync(connection, eventData, result, cancellationToken);
}
public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
base.TransactionCommitted(transaction, eventData);
}
public override Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
return base.TransactionCommittedAsync(transaction, eventData, cancellationToken);
}
public override void TransactionFailed(DbTransaction transaction, TransactionErrorEventData eventData)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
base.TransactionFailed(transaction, eventData);
}
public override Task TransactionFailedAsync(DbTransaction transaction, TransactionErrorEventData eventData, CancellationToken cancellationToken = default)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
return base.TransactionFailedAsync(transaction, eventData, cancellationToken);
}
public override void TransactionRolledBack(DbTransaction transaction, TransactionEndEventData eventData)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
base.TransactionRolledBack(transaction, eventData);
}
public override Task TransactionRolledBackAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
{
DbLock.EndWriteLock(this._logger);
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
WriteLockDepth.Value = currentDepth - 1;
if (currentDepth == 1)
{
DbLock.EndWriteLock(this._logger);
}
}
return base.TransactionRolledBackAsync(transaction, eventData, cancellationToken);
}
@@ -206,6 +266,7 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
private readonly Action? _action;
private bool _disposed;
private bool _lockAcquired;
public DbLock(Action? action = null)
{
@@ -217,17 +278,23 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
#pragma warning restore IDISP015 // Member should not return created and cached instance
{
logger.LogTrace("Enter Write for {Caller}:{Line}", callerMemberName, callerNo);
if (DatabaseLock.IsWriteLockHeld)
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
logger.LogTrace("Write Already Held (Depth: {Depth}) {Caller}:{Line}", currentDepth, callerMemberName, callerNo);
return _noLock;
}
BeginWriteLock(logger, command, callerMemberName, callerNo);
WriteLockDepth.Value = currentDepth + 1;
return new DbLock(() =>
{
WriteLockDepth.Value = Math.Max(0, WriteLockDepth.Value - 1);
EndWriteLock(logger, callerMemberName, callerNo);
});
})
{
_lockAcquired = true
};
}
#pragma warning disable IDISP015 // Member should not return created and cached instance
@@ -235,9 +302,10 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
#pragma warning restore IDISP015 // Member should not return created and cached instance
{
logger.LogTrace("Enter Read {Caller}:{Line}", callerMemberName, callerNo);
if (DatabaseLock.IsWriteLockHeld)
var currentDepth = WriteLockDepth.Value;
if (currentDepth > 0)
{
logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
logger.LogTrace("Write Already Held (Depth: {Depth}) {Caller}:{Line}", currentDepth, callerMemberName, callerNo);
return _noLock;
}
@@ -245,7 +313,10 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
return new DbLock(() =>
{
ExitReadLock(logger, callerMemberName, callerNo);
});
})
{
_lockAcquired = true
};
}
public static void BeginWriteLock(ILogger logger, IDbCommand? command = null, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
@@ -299,7 +370,7 @@ public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
}
this._disposed = true;
if (this._action is not null)
if (this._lockAcquired && this._action is not null)
{
this._action();
}
@@ -97,6 +97,94 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
}
}
/// <summary>
/// Ensures the PostgreSQL database exists, creating it if necessary.
/// </summary>
/// <param name="databaseConfiguration">The database configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public async Task EnsureDatabaseExistsAsync(DatabaseConfigurationOptions databaseConfiguration, CancellationToken cancellationToken = default)
{
static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> converter, Func<T>? defaultValue = null)
{
if (options is null)
{
return defaultValue is not null ? defaultValue() : default;
}
var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (value is null)
{
return defaultValue is not null ? defaultValue() : default;
}
return converter(value.Value);
}
var customOptions = databaseConfiguration.CustomProviderOptions?.Options;
var host = GetOption(customOptions, "host", e => e, () => "localhost")!;
var port = GetOption(customOptions, "port", int.Parse, () => 5432);
var database = GetOption(customOptions, "database", e => e, () => "jellyfin")!;
var username = GetOption(customOptions, "username", e => e, () => "jellyfin")!;
var password = GetOption(customOptions, "password", e => e, () => string.Empty)!;
var timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15);
// Connect to 'postgres' maintenance database to check if target database exists
var maintenanceConnectionBuilder = new NpgsqlConnectionStringBuilder
{
Host = host,
Port = port,
Database = "postgres", // Connect to maintenance database
Username = username,
Password = password,
Timeout = timeout,
Pooling = false // Don't pool maintenance connections
};
try
{
await using var connection = new NpgsqlConnection(maintenanceConnectionBuilder.ToString());
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
// Check if database exists
var checkQuery = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
await using var checkCommand = new NpgsqlCommand(checkQuery, connection);
checkCommand.Parameters.AddWithValue("databaseName", database);
var exists = await checkCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
if (exists is null)
{
// Database doesn't exist, create it
logger.LogInformation("PostgreSQL database '{Database}' does not exist. Creating...", database);
// Use identifier quoting for safety
var createQuery = $"CREATE DATABASE \"{database}\" OWNER \"{username}\"";
await using var createCommand = new NpgsqlCommand(createQuery, connection);
await createCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("PostgreSQL database '{Database}' created successfully", database);
// Grant privileges
var grantQuery = $"GRANT ALL PRIVILEGES ON DATABASE \"{database}\" TO \"{username}\"";
await using var grantCommand = new NpgsqlCommand(grantQuery, connection);
await grantCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
logger.LogInformation("Granted all privileges on database '{Database}' to user '{Username}'", database, username);
}
else
{
logger.LogInformation("PostgreSQL database '{Database}' already exists", database);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to ensure PostgreSQL database '{Database}' exists. Error: {Message}", database, ex.Message);
throw;
}
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
{