/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * * File: ArchiveJournal.cs * * * * This program is free software: you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * * * * You should have received a copy of the GNU General Public License * * along with this program. If not, see . * *************************************************************************/ using System; using System.Collections.Generic; using System.IO; using Server.Json; using Server.Logging; namespace Server.Saves; public enum ArchiveOperationState { Started, Archived, Distributed, Completed, Failed } public class ArchiveJournalEntry { public string Id { get; set; } public ArchivePeriod Period { get; set; } public DateTime RangeStart { get; set; } public ArchiveOperationState State { get; set; } public string ArchiveFile { get; set; } public string TempFile { get; set; } public List SourceDirectories { get; set; } = []; public int EntryCount { get; set; } public long TotalSourceBytes { get; set; } public long ArchiveBytes { get; set; } public DateTime StartedAt { get; set; } public DateTime CompletedAt { get; set; } = DateTime.MaxValue; public string FailureReason { get; set; } public Dictionary DestinationResults { get; set; } = new(); } public class ArchiveJournalData { public List Operations { get; set; } = []; } public static class ArchiveJournal { private const int MaxJournalEntries = 100; private static readonly ILogger logger = LogFactory.GetLogger(typeof(ArchiveJournal)); private static string _journalPath; private static ArchiveJournalData _data = new(); public static IReadOnlyList Operations => _data.Operations; public static void Configure(string archivePath) { _journalPath = Path.Combine(archivePath, ".archive-journal.json"); _data = new ArchiveJournalData(); Load(); } private static void Load() { try { if (File.Exists(_journalPath)) { _data = JsonConfig.Deserialize(_journalPath) ?? new ArchiveJournalData(); } } catch (Exception ex) { logger.Error(ex, "Failed to load archive journal, starting fresh"); _data = new ArchiveJournalData(); } } private static void Save() { try { var tempPath = _journalPath + ".tmp"; JsonConfig.Serialize(tempPath, _data); if (File.Exists(_journalPath)) { File.Delete(_journalPath); } File.Move(tempPath, _journalPath); } catch (Exception ex) { logger.Error(ex, "Failed to save archive journal"); } } private static void Prune() { if (_data.Operations.Count > MaxJournalEntries) { // Remove oldest completed/failed entries beyond the limit var toRemove = _data.Operations.Count - MaxJournalEntries; for (var i = _data.Operations.Count - 1; i >= 0 && toRemove > 0; i--) { if (_data.Operations[i].State is ArchiveOperationState.Completed or ArchiveOperationState.Failed) { _data.Operations.RemoveAt(i); toRemove--; } } } } public static ArchiveJournalEntry BeginOperation( ArchivePeriod period, DateTime rangeStart, string tempFile, string archiveFile, List sourceDirectories) { var entry = new ArchiveJournalEntry { Id = $"{rangeStart:O}-{period.ToString().ToLowerInvariant()}", Period = period, RangeStart = rangeStart, State = ArchiveOperationState.Started, TempFile = tempFile, ArchiveFile = archiveFile, SourceDirectories = sourceDirectories, StartedAt = Core.Now }; _data.Operations.Add(entry); Prune(); Save(); return entry; } public static void RecordArchived(ArchiveJournalEntry entry, int entryCount, long archiveBytes) { entry.State = ArchiveOperationState.Archived; entry.EntryCount = entryCount; entry.ArchiveBytes = archiveBytes; entry.TempFile = null; // Temp file has been renamed to final Save(); } public static void RecordDistributed(ArchiveJournalEntry entry, Dictionary results) { entry.State = ArchiveOperationState.Distributed; entry.DestinationResults = results; Save(); } public static void RecordCompleted(ArchiveJournalEntry entry) { entry.State = ArchiveOperationState.Completed; entry.CompletedAt = Core.Now; Save(); } public static void RecordFailure(ArchiveJournalEntry entry, string reason) { entry.State = ArchiveOperationState.Failed; entry.FailureReason = reason; entry.CompletedAt = Core.Now; Save(); } /// /// Recovers from interrupted operations on startup. /// Handles missing files/directories gracefully (e.g. admin cleaned up archives folder). /// public static void RecoverInterrupted() { var recovered = false; foreach (var entry in _data.Operations) { switch (entry.State) { case ArchiveOperationState.Started: { // Archive creation was interrupted. Clean up temp file if it exists. if (entry.TempFile != null) { try { if (File.Exists(entry.TempFile)) { File.Delete(entry.TempFile); } } catch (Exception ex) { logger.Warning(ex, "Failed to clean up temp file {File}", entry.TempFile); } } logger.Warning( "Recovered interrupted archive operation {Id} (was in Started state)", entry.Id ); entry.State = ArchiveOperationState.Failed; entry.FailureReason = "Interrupted during archive creation"; entry.CompletedAt = Core.Now; recovered = true; break; } case ArchiveOperationState.Archived: { // Archive was created but not distributed. // If the archive file was deleted (admin cleanup), mark as failed. if (entry.ArchiveFile != null && !File.Exists(entry.ArchiveFile)) { logger.Warning( "Archive file for operation {Id} no longer exists (manually deleted?). Marking failed.", entry.Id ); entry.State = ArchiveOperationState.Failed; entry.FailureReason = "Archive file missing on recovery"; entry.CompletedAt = Core.Now; recovered = true; } else { logger.Warning( "Found unfinished archive operation {Id} (Archived but not distributed). " + "Will re-attempt distribution on next cycle.", entry.Id ); } break; } case ArchiveOperationState.Distributed: { // Sources should have been pruned but weren't. logger.Information( "Completing interrupted operation {Id} (was Distributed, pruning sources)", entry.Id ); entry.State = ArchiveOperationState.Completed; entry.CompletedAt = Core.Now; recovered = true; break; } } } if (recovered) { Save(); } } }