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:
2026-02-23 09:38:22 -05:00
parent ede6904433
commit 86883cd5c6
51 changed files with 6719 additions and 187 deletions
@@ -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);