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:
Binary file not shown.
Binary file not shown.
+1
-1
@@ -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
@@ -1 +1 @@
|
||||
79af6817c2e15e73182d4e4594eab8c486fd3066d87d7dd560325ec905918c97
|
||||
3d4d85062f927bfd4145d798a69747c46d5f35ab7d8ff4fb93a81f8cd87c4784
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+86
-15
@@ -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();
|
||||
}
|
||||
|
||||
+1690
File diff suppressed because it is too large
Load Diff
+1146
File diff suppressed because it is too large
Load Diff
+1687
File diff suppressed because it is too large
Load Diff
+88
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user