Refactor PostgreSQL provider: multi-schema & async prep
- Refactor migrations and provider to use multiple PostgreSQL schemas, each matching a legacy SQLite database (activitylog, authentication, displaypreferences, library, users). - All tables, foreign keys, and indexes are now schema-qualified; Down migration drops tables by schema. - Provider ensures schemas exist before migrations; entities are mapped to correct schemas in OnModelCreating. - Add support for max-pool-size, min-pool-size, and multiplexing connection options; update logging accordingly. - VACUUM ANALYZE now runs per schema during scheduled optimization. - TruncateAllTablesAsync now truncates tables with schema qualification. - README updated with schema structure, new options, and multiplexing warnings. - CacheDecorator now calls async repository methods using .GetAwaiter().GetResult(), with documentation. - Lays groundwork for full async/await and multiplexing support in the database layer.
This commit is contained in:
+188
-6
@@ -2,6 +2,8 @@
|
||||
// Copyright (c) PlaceholderCompany. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
#pragma warning disable SA1201 // Elements should appear in the correct order
|
||||
|
||||
namespace Jellyfin.Database.Providers.Postgres;
|
||||
|
||||
using System;
|
||||
@@ -12,6 +14,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Entities.Security;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -37,6 +41,42 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema names mapping to legacy database files.
|
||||
/// </summary>
|
||||
public static class Schemas
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema for activity log tables (legacy: activitylog.db).
|
||||
/// </summary>
|
||||
public const string ActivityLog = "activitylog";
|
||||
|
||||
/// <summary>
|
||||
/// Schema for authentication tables (legacy: authentication.db).
|
||||
/// </summary>
|
||||
public const string Authentication = "authentication";
|
||||
|
||||
/// <summary>
|
||||
/// Schema for display preferences tables (legacy: displaypreferences.db).
|
||||
/// </summary>
|
||||
public const string DisplayPreferences = "displaypreferences";
|
||||
|
||||
/// <summary>
|
||||
/// Schema for library tables (legacy: library.db).
|
||||
/// </summary>
|
||||
public const string Library = "library";
|
||||
|
||||
/// <summary>
|
||||
/// Schema for user tables (legacy: users.db).
|
||||
/// </summary>
|
||||
public const string Users = "users";
|
||||
|
||||
/// <summary>
|
||||
/// Gets all schema names.
|
||||
/// </summary>
|
||||
public static string[] All => [ActivityLog, Authentication, DisplayPreferences, Library, Users];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
@@ -70,19 +110,24 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
Password = GetOption(customOptions, "password", e => e, () => string.Empty)!,
|
||||
Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true),
|
||||
CommandTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30),
|
||||
Timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15)
|
||||
Timeout = GetOption(customOptions, "connection-timeout", int.Parse, () => 15),
|
||||
Multiplexing = GetOption(customOptions, "multiplexing", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false),
|
||||
MaxPoolSize = GetOption(customOptions, "max-pool-size", int.Parse, () => 100),
|
||||
MinPoolSize = GetOption(customOptions, "min-pool-size", int.Parse, () => 0)
|
||||
};
|
||||
|
||||
var connectionString = connectionBuilder.ToString();
|
||||
|
||||
// Log PostgreSQL connection parameters (without password)
|
||||
logger.LogInformation(
|
||||
"PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}",
|
||||
"PostgreSQL connection: Host={Host}, Port={Port}, Database={Database}, Username={Username}, Pooling={Pooling}, MaxPoolSize={MaxPoolSize}, Multiplexing={Multiplexing}",
|
||||
connectionBuilder.Host,
|
||||
connectionBuilder.Port,
|
||||
connectionBuilder.Database,
|
||||
connectionBuilder.Username,
|
||||
connectionBuilder.Pooling);
|
||||
connectionBuilder.Pooling,
|
||||
connectionBuilder.MaxPoolSize,
|
||||
connectionBuilder.Multiplexing);
|
||||
|
||||
options
|
||||
.UseNpgsql(
|
||||
@@ -177,6 +222,9 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
{
|
||||
logger.LogInformation("PostgreSQL database '{Database}' already exists", database);
|
||||
}
|
||||
|
||||
// Now ensure all schemas exist in the database
|
||||
await EnsureSchemasExistAsync(host, port, database, username, password, timeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -185,14 +233,85 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures all required schemas exist in the PostgreSQL database.
|
||||
/// </summary>
|
||||
private async Task EnsureSchemasExistAsync(
|
||||
string host,
|
||||
int port,
|
||||
string database,
|
||||
string username,
|
||||
string password,
|
||||
int timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionBuilder = new NpgsqlConnectionStringBuilder
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Database = database,
|
||||
Username = username,
|
||||
Password = password,
|
||||
Timeout = timeout,
|
||||
Pooling = false
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionBuilder.ToString());
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var schema in Schemas.All)
|
||||
{
|
||||
// Check if schema exists
|
||||
var checkQuery = "SELECT 1 FROM information_schema.schemata WHERE schema_name = @schemaName";
|
||||
await using var checkCommand = new NpgsqlCommand(checkQuery, connection);
|
||||
checkCommand.Parameters.AddWithValue("schemaName", schema);
|
||||
|
||||
var exists = await checkCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (exists is null)
|
||||
{
|
||||
// Schema doesn't exist, create it
|
||||
logger.LogInformation("Creating PostgreSQL schema '{Schema}'...", schema);
|
||||
|
||||
var createQuery = $"CREATE SCHEMA \"{schema}\" AUTHORIZATION \"{username}\"";
|
||||
await using var createCommand = new NpgsqlCommand(createQuery, connection);
|
||||
await createCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation("Schema '{Schema}' created successfully", schema);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Schema '{Schema}' already exists", schema);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("All required PostgreSQL schemas are ready");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to ensure PostgreSQL schemas exist. Error: {Message}", ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
// Run PostgreSQL optimization commands
|
||||
await context.Database.ExecuteSqlRawAsync("VACUUM ANALYZE", cancellationToken).ConfigureAwait(false);
|
||||
// Run PostgreSQL optimization commands on all schemas
|
||||
// Note: VACUUM cannot be parameterized, but schema names are from const strings, not user input
|
||||
foreach (var schema in Schemas.All)
|
||||
{
|
||||
#pragma warning disable EF1002 // Schema names are internal constants, not user input
|
||||
await context.Database.ExecuteSqlRawAsync($"VACUUM ANALYZE \"{schema}\"", cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore EF1002
|
||||
logger.LogDebug("Optimized PostgreSQL schema '{Schema}'", schema);
|
||||
}
|
||||
|
||||
logger.LogInformation("PostgreSQL database optimized successfully!");
|
||||
}
|
||||
}
|
||||
@@ -201,6 +320,56 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
public void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
|
||||
|
||||
// Assign entities to schemas based on their legacy database origin
|
||||
AssignEntitiesToSchemas(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns entities to PostgreSQL schemas based on their legacy database origin.
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">The model builder.</param>
|
||||
private static void AssignEntitiesToSchemas(ModelBuilder modelBuilder)
|
||||
{
|
||||
// ActivityLog schema (legacy: activitylog.db)
|
||||
modelBuilder.Entity<ActivityLog>().ToTable("ActivityLogs", Schemas.ActivityLog);
|
||||
|
||||
// Authentication schema (legacy: authentication.db)
|
||||
modelBuilder.Entity<ApiKey>().ToTable("ApiKeys", Schemas.Authentication);
|
||||
modelBuilder.Entity<Device>().ToTable("Devices", Schemas.Authentication);
|
||||
modelBuilder.Entity<DeviceOptions>().ToTable("DeviceOptions", Schemas.Authentication);
|
||||
|
||||
// DisplayPreferences schema (legacy: displaypreferences.db)
|
||||
modelBuilder.Entity<DisplayPreferences>().ToTable("DisplayPreferences", Schemas.DisplayPreferences);
|
||||
modelBuilder.Entity<ItemDisplayPreferences>().ToTable("ItemDisplayPreferences", Schemas.DisplayPreferences);
|
||||
modelBuilder.Entity<CustomItemDisplayPreferences>().ToTable("CustomItemDisplayPreferences", Schemas.DisplayPreferences);
|
||||
modelBuilder.Entity<HomeSection>().ToTable("HomeSections", Schemas.DisplayPreferences);
|
||||
|
||||
// Users schema (legacy: users.db)
|
||||
modelBuilder.Entity<User>().ToTable("Users", Schemas.Users);
|
||||
modelBuilder.Entity<Permission>().ToTable("Permissions", Schemas.Users);
|
||||
modelBuilder.Entity<Preference>().ToTable("Preferences", Schemas.Users);
|
||||
modelBuilder.Entity<AccessSchedule>().ToTable("AccessSchedules", Schemas.Users);
|
||||
|
||||
// Library schema (legacy: library.db) - All remaining entities
|
||||
modelBuilder.Entity<BaseItemEntity>().ToTable("BaseItems", Schemas.Library);
|
||||
modelBuilder.Entity<Chapter>().ToTable("Chapters", Schemas.Library);
|
||||
modelBuilder.Entity<MediaStreamInfo>().ToTable("MediaStreamInfos", Schemas.Library);
|
||||
modelBuilder.Entity<AttachmentStreamInfo>().ToTable("AttachmentStreamInfos", Schemas.Library);
|
||||
modelBuilder.Entity<ImageInfo>().ToTable("ImageInfos", Schemas.Library);
|
||||
modelBuilder.Entity<BaseItemImageInfo>().ToTable("BaseItemImageInfos", Schemas.Library);
|
||||
modelBuilder.Entity<BaseItemProvider>().ToTable("BaseItemProviders", Schemas.Library);
|
||||
modelBuilder.Entity<BaseItemMetadataField>().ToTable("BaseItemMetadataFields", Schemas.Library);
|
||||
modelBuilder.Entity<BaseItemTrailerType>().ToTable("BaseItemTrailerTypes", Schemas.Library);
|
||||
modelBuilder.Entity<ItemValue>().ToTable("ItemValues", Schemas.Library);
|
||||
modelBuilder.Entity<ItemValueMap>().ToTable("ItemValuesMap", Schemas.Library);
|
||||
modelBuilder.Entity<People>().ToTable("Peoples", Schemas.Library);
|
||||
modelBuilder.Entity<PeopleBaseItemMap>().ToTable("PeopleBaseItemMap", Schemas.Library);
|
||||
modelBuilder.Entity<UserData>().ToTable("UserData", Schemas.Library);
|
||||
modelBuilder.Entity<AncestorId>().ToTable("AncestorIds", Schemas.Library);
|
||||
modelBuilder.Entity<TrickplayInfo>().ToTable("TrickplayInfos", Schemas.Library);
|
||||
modelBuilder.Entity<MediaSegment>().ToTable("MediaSegments", Schemas.Library);
|
||||
modelBuilder.Entity<KeyframeData>().ToTable("KeyframeData", Schemas.Library);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -255,7 +424,20 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
|
||||
var deleteQueries = new List<string>();
|
||||
foreach (var tableName in tableNames)
|
||||
{
|
||||
deleteQueries.Add($"TRUNCATE TABLE \"{tableName}\" CASCADE;");
|
||||
// Find the schema for this table
|
||||
var entityType = dbContext.Model.GetEntityTypes()
|
||||
.FirstOrDefault(et => et.GetTableName() == tableName);
|
||||
|
||||
if (entityType is not null)
|
||||
{
|
||||
var schema = entityType.GetSchema() ?? "public";
|
||||
deleteQueries.Add($"TRUNCATE TABLE \"{schema}\".\"{tableName}\" CASCADE;");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to public schema if entity not found
|
||||
deleteQueries.Add($"TRUNCATE TABLE \"public\".\"{tableName}\" CASCADE;");
|
||||
}
|
||||
}
|
||||
|
||||
var deleteAllQuery = string.Join('\n', deleteQueries);
|
||||
|
||||
Reference in New Issue
Block a user