Add PostgresSubprocessException and refactor database initialization logic

- Introduced PostgresSubprocessException to handle errors during PostgreSQL subprocess execution.
- Refactored PostgresDatabaseProvider to improve schema initialization script discovery.
- Enhanced error handling for psql command execution, including logging of output and errors.
- Implemented methods to find the schema initialization script and the psql executable path.
This commit is contained in:
2026-07-08 11:53:36 -04:00
parent 4f651fc239
commit 1abf61a05f
52 changed files with 723 additions and 9854268 deletions
@@ -0,0 +1,39 @@
// <copyright file="PostgresSubprocessException.cs" company="PlaceholderCompany">
// Copyright (c) PlaceholderCompany. All rights reserved.
// </copyright>
namespace Jellyfin.Database.Providers.Postgres.Exceptions;
using System;
/// <summary>
/// Represents an error that occurs during the execution of a PostgreSQL subprocess (e.g., psql, pg_dump).
/// </summary>
public class PostgresSubprocessException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PostgresSubprocessException"/> class.
/// </summary>
public PostgresSubprocessException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="PostgresSubprocessException"/> class with a specified error message.
/// </summary>
/// <param name="message">The message that describes the error.</param>
public PostgresSubprocessException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="PostgresSubprocessException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
/// </summary>
/// <param name="message">The error message that explains the reason for the exception.</param>
/// <param name="innerException">The exception that is the cause of the current exception, or a null reference if no inner exception is specified.</param>
public PostgresSubprocessException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -8,7 +8,6 @@ namespace Jellyfin.Database.Providers.Postgres;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -552,91 +551,71 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
// Database tables don't exist - initialize from SQL script
logger.LogInformation("Database tables not found (users.Users table missing). Initializing from SQL script...");
var schemaScriptPath = Path.Combine(AppContext.BaseDirectory, "sql", "schema_init", "create_database_schema.sql");
if (!File.Exists(schemaScriptPath))
var createDbScriptPath = FindSchemaScriptPath();
if (string.IsNullOrEmpty(createDbScriptPath))
{
logger.LogError("Schema script not found at: {Path}", schemaScriptPath);
logger.LogError("Cannot initialize database automatically. Please run the SQL script manually.");
throw new FileNotFoundException($"Schema initialization script not found: {schemaScriptPath}");
throw new FileNotFoundException("Could not find the schema initialization script: create_database_schema.sql");
}
logger.LogInformation("Found schema script at: {Path}", schemaScriptPath);
logger.LogInformation("Executing create_database_schema.sql (this may take several minutes)...");
var psqlPath = FindPsqlPath();
try
// The SQL file contains psql meta-commands (\restrict, etc.) that can't be executed via ExecuteNonQuery
// We need to use psql subprocess instead
var connectionString = connection.ConnectionString;
var builder = new NpgsqlConnectionStringBuilder(connectionString);
// Build psql command
var psqlArgs = $"-h {builder.Host} -p {builder.Port} -U {builder.Username} -d {builder.Database} -f \"{createDbScriptPath}\"";
// Log command (password not in args, so safe to log)
logger.LogInformation("Executing via psql: psql {Args}", psqlArgs);
var psqlProcess = new System.Diagnostics.Process
{
// The SQL file contains psql meta-commands (\restrict, etc.) that can't be executed via ExecuteNonQuery
// We need to use psql subprocess instead
var connectionString = connection.ConnectionString;
var builder = new NpgsqlConnectionStringBuilder(connectionString);
// Build psql command
var psqlArgs = $"-h {builder.Host} -p {builder.Port} -U {builder.Username} -d {builder.Database} -f \"{schemaScriptPath}\"";
// Log command (password not in args, so safe to log)
logger.LogInformation("Executing via psql: psql {Args}", psqlArgs);
var psqlProcess = new System.Diagnostics.Process
StartInfo = new System.Diagnostics.ProcessStartInfo
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "psql",
Arguments = psqlArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
// Set password via environment variable
if (!string.IsNullOrEmpty(builder.Password))
{
psqlProcess.StartInfo.EnvironmentVariables["PGPASSWORD"] = builder.Password;
FileName = "psql",
Arguments = psqlArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
psqlProcess.Start();
// Set password via environment variable
if (!string.IsNullOrEmpty(builder.Password))
{
psqlProcess.StartInfo.EnvironmentVariables["PGPASSWORD"] = builder.Password;
}
// Read output
var output = await psqlProcess.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var error = await psqlProcess.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
psqlProcess.Start();
await psqlProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
// Read output
var output = await psqlProcess.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var error = await psqlProcess.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
if (psqlProcess.ExitCode == 0)
await psqlProcess.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (psqlProcess.ExitCode == 0)
{
logger.LogInformation("✅ Successfully initialized database from SQL script via psql");
logger.LogInformation("Database is ready. You can now start Jellyfin.");
if (!string.IsNullOrWhiteSpace(output))
{
logger.LogInformation("✅ Successfully initialized database from SQL script via psql");
logger.LogInformation("Database is ready. You can now start Jellyfin.");
if (!string.IsNullOrWhiteSpace(output))
{
logger.LogDebug("psql output: {Output}", output);
}
}
else
{
logger.LogError("psql exited with code {ExitCode}", psqlProcess.ExitCode);
if (!string.IsNullOrWhiteSpace(error))
{
logger.LogError("psql error: {Error}", error);
}
throw new InvalidOperationException($"psql failed with exit code {psqlProcess.ExitCode}. Error: {error}");
logger.LogDebug("psql output: {Output}", output);
}
}
catch (System.ComponentModel.Win32Exception psqlNotFound)
else
{
// psql not found in PATH - fallback to manual instructions
logger.LogWarning(psqlNotFound, "psql command not found in PATH. Cannot automatically initialize database.");
logger.LogError("Please run the SQL script manually: psql -U postgres -d jellyfin_testdata -f {Path}", schemaScriptPath);
throw new FileNotFoundException("psql command not found. Please install PostgreSQL client tools and ensure psql is in your PATH, or run the SQL script manually.", psqlNotFound);
}
catch (Exception scriptEx)
{
logger.LogError(scriptEx, "Failed to execute schema creation script");
logger.LogError("Please run the SQL script manually: psql -U postgres -d jellyfin_testdata -f {Path}", schemaScriptPath);
throw;
logger.LogError("psql exited with code {ExitCode}", psqlProcess.ExitCode);
if (!string.IsNullOrWhiteSpace(error))
{
logger.LogError("psql error: {Error}", error);
}
throw new InvalidOperationException($"psql failed with exit code {psqlProcess.ExitCode}. Error: {error}");
}
}
else
@@ -1044,4 +1023,63 @@ public sealed class PostgresDatabaseProvider : IJellyfinDatabaseProvider
await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false);
}
/// <summary>
/// Finds the full path to the schema initialization script.
/// </summary>
/// <returns>The full path to the script, or null if not found.</returns>
private string? FindSchemaScriptPath()
{
var scriptName = Path.Combine("schema_init", "create_database_schema.sql");
// 1. Check relative to the executing assembly
var assemblyLocation = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (!string.IsNullOrEmpty(assemblyLocation))
{
var assemblySqlPath = Path.Combine(assemblyLocation, "sql", scriptName);
if (File.Exists(assemblySqlPath))
{
logger.LogInformation("Found schema script at assembly path: {Path}", assemblySqlPath);
return assemblySqlPath;
}
}
// 2. Check relative to the current working directory
var currentDirSqlPath = Path.Combine(Directory.GetCurrentDirectory(), "sql", scriptName);
if (File.Exists(currentDirSqlPath))
{
logger.LogInformation("Found schema script at current directory path: {Path}", currentDirSqlPath);
return currentDirSqlPath;
}
// 3. Fallback for development environment (navigate up from assembly location)
if (!string.IsNullOrEmpty(assemblyLocation))
{
var dir = new DirectoryInfo(assemblyLocation);
var root = dir.Parent?.Parent?.Parent?.Parent?.Parent; // Adjust depth as needed
if (root != null)
{
var devSqlPath = Path.Combine(root.FullName, "sql", scriptName);
if (File.Exists(devSqlPath))
{
logger.LogInformation("Found schema script at development path: {Path}", devSqlPath);
return devSqlPath;
}
}
}
logger.LogError("Schema initialization script not found in any known location.");
return null;
}
/// <summary>
/// Finds the path to the psql executable.
/// </summary>
/// <returns>The path to psql, or just "psql" if it's in the system PATH.</returns>
private static string FindPsqlPath()
{
// For now, assume psql is in the PATH.
// Future improvements could search known installation directories.
return "psql";
}
}