using Sonex.Data.Database; using Sonex.Library.WorkersCore; using System.Diagnostics; using System.ServiceProcess; using System.Text; using WorkerCore = Sonex.Library.WorkersCore.Worker; namespace Sonex.Worker.DatabaseBackup; internal sealed class DatabaseBackupWindowsService : ServiceBase { private static readonly TimeSpan StatusReportInterval = TimeSpan.FromSeconds(30); private const string WorkerName = "Sonex.Worker.DatabaseBackup"; private const string WorkerTitle = "PostgreSQL Base Backup"; private const string WorkerDescription = "Creates PostgreSQL base backup and copies completed backup to optional additional locations."; private const string WindowsServiceStartMode = "auto"; private const string OperationWorkerLifecycle = "WorkerLifecycle"; private const string OperationCycleExecution = "CycleExecution"; private const string OperationWalArchiveCheck = "WalArchiveCheck"; private const string OperationBaseBackup = "BaseBackup"; private const string OperationCopyBackup = "CopyBackup"; private const string PgBaseBackupPathKey = "PG_BASEBACKUP_PATH"; private const string PgHostKey = "PG_HOST"; private const string PgPortKey = "PG_PORT"; private const string PgUserKey = "PG_USER"; private const string PgNoPasswordPromptKey = "PG_NO_PASSWORD_PROMPT"; private const string PgWalMethodKey = "PG_WAL_METHOD"; private const string PgCheckpointModeKey = "PG_CHECKPOINT_MODE"; private const string PgCompressionKey = "PG_COMPRESSION"; private const string BackupRootPathKey = "BACKUP_ROOT_PATH"; private const string BackupFolderPrefixKey = "BACKUP_FOLDER_PREFIX"; private const string CopyTarget1Key = "COPY_TARGET_1"; private const string CopyTarget2Key = "COPY_TARGET_2"; private const string CopyTarget3Key = "COPY_TARGET_3"; private const string RequireWalArchiveKey = "REQUIRE_WAL_ARCHIVE"; private readonly object _lifecycleLock = new(); private readonly ManualResetEventSlim _resumeSignal = new(initialState: true); private CancellationTokenSource? _stoppingCts; private CancellationTokenSource? _statusReportingCts; private Task? _runTask; private Task? _statusReportingTask; private bool _isConsoleMode; private DateTime _runStartedAtUtc; private DatabaseBackupRunReport _runReport = new(); public DatabaseBackupWindowsService() { ServiceName = WorkerName; CanStop = true; CanPauseAndContinue = true; AutoLog = false; } public void RunConsole() { _isConsoleMode = true; OnStart([]); Task? runTask; lock (_lifecycleLock) { runTask = _runTask; } try { runTask?.Wait(); } catch (AggregateException) { } OnStop(); } protected override void OnStart(string[] args) { lock (_lifecycleLock) { if (_runTask is not null) return; WorkerCore.Name = WorkerName; WorkerCore.Title = WorkerTitle; WorkerCore.Description = WorkerDescription; WorkerCore.WorkType = WorkerWorkType.Interval; ConfigureWorkerParameters(); WorkerCore.Registration( ensureWindowsServiceInConsole: _isConsoleMode, serviceStartMode: WindowsServiceStartMode, serviceDescription: WorkerDescription); WorkerCore.WorkerStarted(); WorkerCore.UpdateActivity("Idle"); WorkerCore.UpdateStatus(); WorkerCore.LogInfo("Worker started.", OperationWorkerLifecycle); _runStartedAtUtc = DateTime.UtcNow; _runReport = new DatabaseBackupRunReport(); _stoppingCts = new CancellationTokenSource(); _statusReportingCts = CancellationTokenSource.CreateLinkedTokenSource(_stoppingCts.Token); _resumeSignal.Set(); _runTask = Task.Run(() => RunAsync(_stoppingCts.Token)); _statusReportingTask = Task.Run(() => StatusReportingLoopAsync(_statusReportingCts.Token)); } } protected override void OnPause() { lock (_lifecycleLock) { _resumeSignal.Reset(); WorkerCore.WorkerPaused(); WorkerCore.UpdateStatus(); WorkerCore.LogInfo( $"Worker paused. Progress snapshot: {BuildProgressSnapshot()}.", OperationWorkerLifecycle); } } protected override void OnContinue() { lock (_lifecycleLock) { _resumeSignal.Set(); WorkerCore.WorkerResumed(); WorkerCore.UpdateStatus(); WorkerCore.LogInfo( $"Worker resumed. Progress snapshot: {BuildProgressSnapshot()}.", OperationWorkerLifecycle); } } protected override void OnStop() { Task? runTask; Task? statusReportingTask; CancellationTokenSource? cts; CancellationTokenSource? statusReportingCts; string stopProgressSnapshot = BuildProgressSnapshot(); lock (_lifecycleLock) { runTask = _runTask; statusReportingTask = _statusReportingTask; cts = _stoppingCts; statusReportingCts = _statusReportingCts; _runTask = null; _statusReportingTask = null; _stoppingCts = null; _statusReportingCts = null; _resumeSignal.Set(); } WorkerCore.LogInfo( $"Worker stop requested. Progress snapshot: {stopProgressSnapshot}.", OperationWorkerLifecycle); if (cts is not null) { try { cts.Cancel(); } finally { cts.Dispose(); } } if (statusReportingCts is not null) { try { statusReportingCts.Cancel(); } finally { statusReportingCts.Dispose(); } } try { runTask?.Wait(TimeSpan.FromSeconds(30)); } catch (AggregateException) { } try { statusReportingTask?.Wait(TimeSpan.FromSeconds(5)); } catch (AggregateException) { } WorkerCore.WorkerFinished(); WorkerCore.UpdateStatus(); TimeSpan runDuration = BuildRunDuration(); string finishSummary = _runReport.BuildSummary(runDuration); string fullReport = _runReport.BuildFullReport(runDuration); Console.WriteLine(fullReport); WorkerCore.LogInfo(finishSummary, OperationWorkerLifecycle); } protected override void Dispose(bool disposing) { if (disposing) _resumeSignal.Dispose(); base.Dispose(disposing); } private async Task RunAsync(CancellationToken cancellationToken) { try { WorkerCore.LogInfo("Cycle started.", OperationCycleExecution); _resumeSignal.Wait(cancellationToken); await ExecuteCycleAsync(cancellationToken).ConfigureAwait(false); if (!cancellationToken.IsCancellationRequested) { WorkerCore.UpdateActivity("Completed"); WorkerCore.UpdateStatus(); WorkerCore.LogInfo("Cycle completed.", OperationCycleExecution); if (!_isConsoleMode) { _ = Task.Run(Stop); } } } catch (OperationCanceledException) { } catch (Exception ex) { WorkerCore.WorkerError("Cycle error"); WorkerCore.UpdateStatus(); Console.Error.WriteLine(ex.ToString()); WorkerCore.LogError("Cycle execution failed.", ex, OperationCycleExecution); } } private async Task StatusReportingLoopAsync(CancellationToken cancellationToken) { try { using var timer = new PeriodicTimer(StatusReportInterval); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { WorkerCore.UpdateStatus(); } } catch (OperationCanceledException) { } } private async Task ExecuteCycleAsync(CancellationToken cancellationToken) { RuntimeSettings settings = LoadRuntimeSettings(); ValidateRuntimeSettings(settings); _runReport = new DatabaseBackupRunReport(); int totalSteps = settings.CopyTargets.Count + 1; int completedSteps = 0; await WaitIfPausedAsync(cancellationToken).ConfigureAwait(false); if (settings.RequireWalArchive) await ValidateWalArchiveSettingsAsync(cancellationToken).ConfigureAwait(false); await WaitIfPausedAsync(cancellationToken).ConfigureAwait(false); WorkerCore.UpdateActivity("Preparing backup directory"); WorkerCore.UpdateProgress(completedSteps, totalSteps); WorkerCore.UpdateStatus(); Directory.CreateDirectory(settings.BackupRootPath); string backupFolderName = $"{settings.BackupFolderPrefix}_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}"; string backupDirectoryPath = Path.Combine(settings.BackupRootPath, backupFolderName); string backupLogFilePath = Path.Combine(backupDirectoryPath, "backup_run.log"); Directory.CreateDirectory(backupDirectoryPath); _runReport.BackupDirectoryPath = backupDirectoryPath; _runReport.BackupLogFilePath = backupLogFilePath; await WaitIfPausedAsync(cancellationToken).ConfigureAwait(false); WorkerCore.UpdateActivity("Running pg_basebackup"); WorkerCore.UpdateStatus(); int exitCode = await RunPgBaseBackupAsync( settings, backupDirectoryPath, backupLogFilePath, cancellationToken).ConfigureAwait(false); if (exitCode != 0) { throw new InvalidOperationException( $"pg_basebackup failed with exit code {exitCode}. See log file: {backupLogFilePath}"); } completedSteps++; _runReport.BackupSucceeded = true; _runReport.BackupSizeBytes = CalculateDirectorySizeBytes(backupDirectoryPath); WorkerCore.UpdateProgress(completedSteps, totalSteps); WorkerCore.UpdateActivity("Primary backup completed"); WorkerCore.UpdateStatus(); WorkerCore.LogInfo( $"Primary backup completed. Path={backupDirectoryPath}. Size={_runReport.BackupSizeBytes} bytes.", OperationBaseBackup); bool hasCopyFailure = false; foreach (string copyTarget in settings.CopyTargets) { await WaitIfPausedAsync(cancellationToken).ConfigureAwait(false); string destinationPath = Path.Combine(copyTarget, backupFolderName); WorkerCore.UpdateActivity($"Copying backup to {copyTarget}"); WorkerCore.UpdateStatus(); try { CopyDirectory( sourceDirectoryPath: backupDirectoryPath, destinationDirectoryPath: destinationPath, cancellationToken: cancellationToken); _runReport.RegisterCopyResult(copyTarget, true, $"Copied to {destinationPath}"); WorkerCore.LogInfo( $"Backup copy completed. Target={copyTarget}. Destination={destinationPath}.", OperationCopyBackup); } catch (Exception ex) { hasCopyFailure = true; _runReport.RegisterCopyResult(copyTarget, false, ex.Message); WorkerCore.LogError( $"Backup copy failed. Target={copyTarget}. Destination={destinationPath}.", ex, OperationCopyBackup); } completedSteps++; WorkerCore.UpdateProgress(completedSteps, totalSteps); WorkerCore.UpdateStatus(); } if (hasCopyFailure) { throw new InvalidOperationException("One or more additional backup copies failed. See worker logs for details."); } WorkerCore.UpdateActivity("Cycle completed"); WorkerCore.UpdateStatus(); } private static RuntimeSettings LoadRuntimeSettings() { string copyTarget1 = GetParameterString(CopyTarget1Key, string.Empty); string copyTarget2 = GetParameterString(CopyTarget2Key, string.Empty); string copyTarget3 = GetParameterString(CopyTarget3Key, string.Empty); List copyTargets = [ copyTarget1, copyTarget2, copyTarget3 ]; return new RuntimeSettings { PgBaseBackupPath = GetParameterString(PgBaseBackupPathKey, @"C:\Program Files\PostgreSQL\17\bin\pg_basebackup.exe"), Host = GetParameterString(PgHostKey, "127.0.0.1"), Port = GetParameterInt(PgPortKey, 5432, 1), UserName = GetParameterString(PgUserKey, "postgres"), NoPasswordPrompt = GetParameterBool(PgNoPasswordPromptKey, true), WalMethod = NormalizeToken( GetParameterString(PgWalMethodKey, "fetch"), "fetch"), CheckpointMode = NormalizeToken( GetParameterString(PgCheckpointModeKey, "fast"), "fast"), Compression = GetParameterString(PgCompressionKey, "zstd:3"), BackupRootPath = GetParameterString(BackupRootPathKey, @"C:\Sonex\PostgreSQL\BaseBackups"), BackupFolderPrefix = GetParameterString(BackupFolderPrefixKey, "pg_backup"), CopyTargets = copyTargets .Where(static path => !string.IsNullOrWhiteSpace(path)) .Select(static path => path.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(), RequireWalArchive = GetParameterBool(RequireWalArchiveKey, true) }; } private static void ValidateRuntimeSettings(RuntimeSettings settings) { if (string.IsNullOrWhiteSpace(settings.PgBaseBackupPath)) throw new InvalidOperationException("PG_BASEBACKUP_PATH cannot be empty."); if (!File.Exists(settings.PgBaseBackupPath)) throw new FileNotFoundException("pg_basebackup executable not found.", settings.PgBaseBackupPath); if (string.IsNullOrWhiteSpace(settings.Host)) throw new InvalidOperationException("PG_HOST cannot be empty."); if (string.IsNullOrWhiteSpace(settings.UserName)) throw new InvalidOperationException("PG_USER cannot be empty."); if (string.IsNullOrWhiteSpace(settings.BackupRootPath)) throw new InvalidOperationException("BACKUP_ROOT_PATH cannot be empty."); if (string.IsNullOrWhiteSpace(settings.BackupFolderPrefix)) throw new InvalidOperationException("BACKUP_FOLDER_PREFIX cannot be empty."); } private static async Task ValidateWalArchiveSettingsAsync(CancellationToken cancellationToken) { WorkerCore.UpdateActivity("Validating WAL archive settings"); WorkerCore.UpdateStatus(); const string sql = """ SELECT current_setting('archive_mode') AS archive_mode, current_setting('archive_command') AS archive_command """; var result = await WorkerCore.ExecuteDatabaseSingleWithRetryAsync( ct => DB.QuerySingleAsync( sql, ct: ct), OperationWalArchiveCheck, cancellationToken) .ConfigureAwait(false); if (!result.Success || result.Item is null) { string error = result.ErrorMessage ?? "Unknown WAL archive settings query error."; throw new InvalidOperationException($"WAL archive settings validation failed. Error={error}"); } string archiveMode = result.Item.ArchiveMode?.Trim() ?? string.Empty; string archiveCommand = result.Item.ArchiveCommand?.Trim() ?? string.Empty; if (!string.Equals(archiveMode, "on", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"WAL archive is not enabled. Expected archive_mode=on, got archive_mode={archiveMode}."); } if (string.IsNullOrWhiteSpace(archiveCommand) || string.Equals(archiveCommand, "(disabled)", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( $"WAL archive command is not configured. Current value={archiveCommand}."); } WorkerCore.LogInfo( $"WAL archive settings verified. archive_mode={archiveMode}, archive_command={archiveCommand}.", OperationWalArchiveCheck); } private static async Task RunPgBaseBackupAsync( RuntimeSettings settings, string backupDirectoryPath, string backupLogFilePath, CancellationToken cancellationToken) { string arguments = BuildPgBaseBackupArguments(settings, backupDirectoryPath); string? backupPassword = Config.GetStringValue("BACKUP_DB_PASSWORD", string.Empty).Trim(); var logLock = new object(); AppendBackupLogLine(logLock, backupLogFilePath, $"Command: {settings.PgBaseBackupPath} {arguments}"); var startInfo = new ProcessStartInfo { FileName = settings.PgBaseBackupPath, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (!string.IsNullOrWhiteSpace(backupPassword)) { startInfo.Environment["PGPASSWORD"] = backupPassword; } using var process = new Process { StartInfo = startInfo }; process.OutputDataReceived += (_, e) => { if (e.Data is null) return; Console.WriteLine(e.Data); AppendBackupLogLine(logLock, backupLogFilePath, e.Data); }; process.ErrorDataReceived += (_, e) => { if (e.Data is null) return; Console.Error.WriteLine(e.Data); AppendBackupLogLine(logLock, backupLogFilePath, "[ERR] " + e.Data); }; if (!process.Start()) return 1; process.BeginOutputReadLine(); process.BeginErrorReadLine(); using var cancellationRegistration = cancellationToken.Register(() => { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } }); try { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { AppendBackupLogLine(logLock, backupLogFilePath, "Backup process canceled."); throw; } AppendBackupLogLine(logLock, backupLogFilePath, $"ExitCode={process.ExitCode}"); return process.ExitCode; } private static string BuildPgBaseBackupArguments(RuntimeSettings settings, string backupDirectoryPath) { var args = new StringBuilder(); AppendOption(args, "-h", settings.Host); AppendOption(args, "-p", settings.Port.ToString()); AppendOption(args, "-U", settings.UserName); AppendOption(args, "-D", backupDirectoryPath); args.Append("-Ft "); args.Append($"-X{settings.WalMethod} "); args.Append($"--checkpoint={settings.CheckpointMode} "); args.Append("-P -v "); if (settings.NoPasswordPrompt) args.Append("-w "); if (!string.IsNullOrWhiteSpace(settings.Compression)) { AppendOption(args, "-Z", settings.Compression); } return args.ToString().Trim(); } private static void AppendOption(StringBuilder target, string name, string value) { target.Append(name); target.Append(' '); target.Append('"'); target.Append(value.Replace("\"", "\\\"")); target.Append("\" "); } private static void AppendBackupLogLine(object logLock, string logFilePath, string message) { string line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}"; lock (logLock) { File.AppendAllText(logFilePath, line + Environment.NewLine, Encoding.UTF8); } } private static void CopyDirectory( string sourceDirectoryPath, string destinationDirectoryPath, CancellationToken cancellationToken) { string sourceFullPath = Path.GetFullPath(sourceDirectoryPath); string destinationFullPath = Path.GetFullPath(destinationDirectoryPath); if (string.Equals(sourceFullPath, destinationFullPath, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Source and destination paths for backup copy are the same."); Directory.CreateDirectory(destinationFullPath); foreach (string directoryPath in Directory.GetDirectories(sourceFullPath, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); string relativePath = Path.GetRelativePath(sourceFullPath, directoryPath); Directory.CreateDirectory(Path.Combine(destinationFullPath, relativePath)); } foreach (string filePath in Directory.GetFiles(sourceFullPath, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); string relativePath = Path.GetRelativePath(sourceFullPath, filePath); string destinationFilePath = Path.Combine(destinationFullPath, relativePath); string? destinationDirectory = Path.GetDirectoryName(destinationFilePath); if (!string.IsNullOrWhiteSpace(destinationDirectory)) Directory.CreateDirectory(destinationDirectory); File.Copy(filePath, destinationFilePath, overwrite: true); } } private static long CalculateDirectorySizeBytes(string directoryPath) { long totalSize = 0; foreach (string filePath in Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)) { try { var fileInfo = new FileInfo(filePath); totalSize += fileInfo.Length; } catch { } } return totalSize; } private ValueTask WaitIfPausedAsync(CancellationToken cancellationToken) { _resumeSignal.Wait(cancellationToken); return ValueTask.CompletedTask; } private static string BuildProgressSnapshot() { string progressInfo = WorkerCore.ProgressInfo?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(progressInfo)) return progressInfo; string progress = WorkerCore.Progress?.Trim() ?? string.Empty; if (!string.IsNullOrWhiteSpace(progress)) return progress; return "0/0 (0%)"; } private TimeSpan BuildRunDuration() { DateTime startedAtUtc = _runStartedAtUtc; if (startedAtUtc == default) return TimeSpan.Zero; TimeSpan duration = DateTime.UtcNow - startedAtUtc; if (duration < TimeSpan.Zero) duration = TimeSpan.Zero; return duration; } private static string NormalizeToken(string value, string defaultValue) { string normalized = value.Trim().ToLowerInvariant(); if (string.IsNullOrWhiteSpace(normalized)) return defaultValue; return normalized; } private static string GetParameterString(string key, string defaultValue) { var parameter = WorkerCore.Parameters .FirstOrDefault(item => string.Equals(item.Parametr, key, StringComparison.OrdinalIgnoreCase)); return string.IsNullOrWhiteSpace(parameter?.Value) ? defaultValue : parameter.Value.Trim(); } private static int GetParameterInt(string key, int defaultValue, int minValue) { string value = GetParameterString(key, defaultValue.ToString()); if (!int.TryParse(value, out int parsed)) return defaultValue; return Math.Max(minValue, parsed); } private static bool GetParameterBool(string key, bool defaultValue) { string value = GetParameterString(key, defaultValue.ToString()); return bool.TryParse(value, out bool parsed) ? parsed : defaultValue; } private static void ConfigureWorkerParameters() { WorkerCore.Parameters.Clear(); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgBaseBackupPathKey, Value = @"C:\Program Files\PostgreSQL\17\bin\pg_basebackup.exe", Title = "pg_basebackup path", Description = "Full path to pg_basebackup executable.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgHostKey, Value = "127.0.0.1", Title = "PostgreSQL host", Description = "Host name or IP used by pg_basebackup.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgPortKey, Value = "5432", Title = "PostgreSQL port", Description = "PostgreSQL server port used by pg_basebackup.", ValueType = WorkerSettingValueTypes.Integer }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgUserKey, Value = "postgres", Title = "PostgreSQL user", Description = "User name for pg_basebackup replication connection.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgNoPasswordPromptKey, Value = "true", Title = "No password prompt", Description = "If true, pg_basebackup uses -w and never prompts for password.", ValueType = WorkerSettingValueTypes.Boolean }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgWalMethodKey, Value = "fetch", Title = "WAL method", Description = "WAL inclusion method passed to pg_basebackup (-X).", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgCheckpointModeKey, Value = "fast", Title = "Checkpoint mode", Description = "Checkpoint mode passed to pg_basebackup (--checkpoint).", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = PgCompressionKey, Value = "zstd:3", Title = "Compression", Description = "Compression passed to pg_basebackup (-Z).", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = BackupRootPathKey, Value = @"C:\Sonex\PostgreSQL\BaseBackups", Title = "Backup root path", Description = "Root folder where worker creates timestamped base backup directories.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = BackupFolderPrefixKey, Value = "pg_backup", Title = "Backup folder prefix", Description = "Prefix used for timestamped backup directory names.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = CopyTarget1Key, Value = string.Empty, Title = "Copy target 1", Description = "Optional destination root for additional backup copy.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = CopyTarget2Key, Value = string.Empty, Title = "Copy target 2", Description = "Optional destination root for additional backup copy.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = CopyTarget3Key, Value = string.Empty, Title = "Copy target 3", Description = "Optional destination root for additional backup copy.", ValueType = WorkerSettingValueTypes.String }); WorkerCore.Parameters.Add(new WorkerParameter { Parametr = RequireWalArchiveKey, Value = "true", Title = "Require WAL archive", Description = "If true, worker validates archive_mode and archive_command before backup.", ValueType = WorkerSettingValueTypes.Boolean }); } private sealed class RuntimeSettings { public string PgBaseBackupPath { get; init; } = string.Empty; public string Host { get; init; } = string.Empty; public int Port { get; init; } public string UserName { get; init; } = string.Empty; public bool NoPasswordPrompt { get; init; } public string WalMethod { get; init; } = "fetch"; public string CheckpointMode { get; init; } = "fast"; public string Compression { get; init; } = string.Empty; public string BackupRootPath { get; init; } = string.Empty; public string BackupFolderPrefix { get; init; } = string.Empty; public IReadOnlyList CopyTargets { get; init; } = []; public bool RequireWalArchive { get; init; } } private sealed class WalArchiveSettingsRow { public string? ArchiveMode { get; init; } public string? ArchiveCommand { get; init; } } }