diff --git a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs index ce2ac17..55fa984 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationMigrator.cs @@ -1,11 +1,17 @@ -using LightlessSync.WebAPI; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.WebAPI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace LightlessSync.LightlessConfiguration; public class ConfigurationMigrator(ILogger logger, TransientConfigService transientConfigService, - ServerConfigService serverConfigService) : IHostedService + ServerConfigService serverConfigService, TempCollectionConfigService tempCollectionConfigService, + LightlessConfigService lightlessConfigService) : IHostedService { private readonly ILogger _logger = logger; @@ -51,6 +57,8 @@ public class ConfigurationMigrator(ILogger logger, Transi serverConfigService.Current.Version = 2; serverConfigService.Save(); } + + MigrateTempCollectionConfig(tempCollectionConfigService, lightlessConfigService); } public Task StartAsync(CancellationToken cancellationToken) @@ -63,4 +71,273 @@ public class ConfigurationMigrator(ILogger logger, Transi { return Task.CompletedTask; } + + private void MigrateTempCollectionConfig(TempCollectionConfigService tempCollectionConfigService, LightlessConfigService lightlessConfigService) + { + var now = DateTime.UtcNow; + TempCollectionConfig tempConfig = tempCollectionConfigService.Current; + var tempChanged = false; + var tempNeedsSave = false; + + if (TryReadTempCollectionData(lightlessConfigService.ConfigurationPath, out var root, out var ids, out var entries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, ids, entries, now); + var removed = root.Remove("OrphanableTempCollections"); + removed |= root.Remove("OrphanableTempCollectionEntries"); + if (removed) + { + try + { + string updatedJson = root.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(lightlessConfigService.ConfigurationPath, updatedJson); + lightlessConfigService.UpdateLastWriteTime(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to rewrite {config} after temp collection migration", lightlessConfigService.ConfigurationPath); + return; + } + } + + if (ids.Count > 0 || entries.Count > 0) + { + _logger.LogInformation("Migrated {ids} temp collection ids and {entries} entries to {configName}", + ids.Count, entries.Count, tempCollectionConfigService.ConfigurationName); + } + } + + if (TryReadTempCollectionData(tempCollectionConfigService.ConfigurationPath, out var tempRoot, out var tempIds, out var tempEntries)) + { + tempChanged |= MergeTempCollectionData(tempConfig, tempIds, tempEntries, now); + if (tempRoot.Remove("OrphanableTempCollections")) + { + tempNeedsSave = true; + } + } + + if (tempChanged || tempNeedsSave) + { + tempCollectionConfigService.Save(); + } + } + + private bool TryReadTempCollectionData(string configPath, out JsonObject root, out HashSet ids, out List entries) + { + root = new JsonObject(); + ids = []; + entries = []; + + if (!File.Exists(configPath)) + { + return false; + } + + try + { + root = JsonNode.Parse(File.ReadAllText(configPath)) as JsonObject ?? new JsonObject(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read temp collection data from {config}", configPath); + return false; + } + + root.TryGetPropertyValue("OrphanableTempCollections", out JsonNode? idsNode); + root.TryGetPropertyValue("OrphanableTempCollectionEntries", out JsonNode? entriesNode); + + if (idsNode == null && entriesNode == null) + { + return false; + } + + ids = ParseGuidSet(idsNode); + entries = ParseEntries(entriesNode); + return true; + } + + private static HashSet ParseGuidSet(JsonNode? node) + { + HashSet ids = []; + if (node is not JsonArray array) + { + return ids; + } + + foreach (JsonNode? item in array) + { + Guid id = ParseGuid(item); + if (id != Guid.Empty) + { + ids.Add(id); + } + } + + return ids; + } + + private static List ParseEntries(JsonNode? node) + { + List entries = []; + if (node is not JsonArray array) + { + return entries; + } + + foreach (JsonNode? item in array) + { + if (item is not JsonObject obj) + { + continue; + } + + Guid id = ParseGuid(obj["Id"]); + if (id == Guid.Empty) + { + continue; + } + + DateTime registeredAtUtc = DateTime.MinValue; + if (TryParseDateTime(obj["RegisteredAtUtc"], out DateTime parsed)) + { + registeredAtUtc = parsed; + } + + entries.Add(new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = registeredAtUtc + }); + } + + return entries; + } + + private static Guid ParseGuid(JsonNode? node) + { + if (node is JsonValue value) + { + if (value.TryGetValue(out string? stringValue) && Guid.TryParse(stringValue, out Guid parsed)) + { + return parsed; + } + } + + return Guid.Empty; + } + + private static bool TryParseDateTime(JsonNode? node, out DateTime value) + { + value = DateTime.MinValue; + if (node is not JsonValue val) + { + return false; + } + + if (val.TryGetValue(out DateTime direct)) + { + value = direct; + return true; + } + + if (val.TryGetValue(out string? stringValue) + && DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsed)) + { + value = parsed; + return true; + } + + return false; + } + + private static bool MergeTempCollectionData(TempCollectionConfig config, HashSet ids, List entries, DateTime now) + { + bool changed = false; + Dictionary entryLookup = new(); + for (var i = config.OrphanableTempCollectionEntries.Count - 1; i >= 0; i--) + { + var entry = config.OrphanableTempCollectionEntries[i]; + if (entry.Id == Guid.Empty) + { + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + if (entryLookup.TryGetValue(entry.Id, out var existing)) + { + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + + config.OrphanableTempCollectionEntries.RemoveAt(i); + changed = true; + continue; + } + + entryLookup[entry.Id] = entry; + } + + foreach (OrphanableTempCollectionEntry entry in entries) + { + if (entry.Id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(entry.Id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = entry.Id, + RegisteredAtUtc = entry.RegisteredAtUtc + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[entry.Id] = added; + changed = true; + continue; + } + + if (entry.RegisteredAtUtc != DateTime.MinValue + && (existing.RegisteredAtUtc == DateTime.MinValue || entry.RegisteredAtUtc < existing.RegisteredAtUtc)) + { + existing.RegisteredAtUtc = entry.RegisteredAtUtc; + changed = true; + } + } + + foreach (Guid id in ids) + { + if (id == Guid.Empty) + { + continue; + } + + if (!entryLookup.TryGetValue(id, out OrphanableTempCollectionEntry? existing)) + { + var added = new OrphanableTempCollectionEntry + { + Id = id, + RegisteredAtUtc = now + }; + config.OrphanableTempCollectionEntries.Add(added); + entryLookup[id] = added; + changed = true; + continue; + } + + if (existing.RegisteredAtUtc == DateTime.MinValue) + { + existing.RegisteredAtUtc = now; + changed = true; + } + } + + return changed; + } } diff --git a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs index 6208dae..b6a097e 100644 --- a/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs +++ b/LightlessSync/LightlessConfiguration/ConfigurationSaveService.cs @@ -72,37 +72,41 @@ public class ConfigurationSaveService : IHostedService { _logger.LogTrace("Saving {configName}", config.ConfigurationName); var configDir = config.ConfigurationPath.Replace(config.ConfigurationName, string.Empty); + var isTempCollections = string.Equals(config.ConfigurationName, TempCollectionConfigService.ConfigName, StringComparison.OrdinalIgnoreCase); - try + if (!isTempCollections) { - var configBackupFolder = Path.Join(configDir, BackupFolder); - if (!Directory.Exists(configBackupFolder)) - Directory.CreateDirectory(configBackupFolder); - - var configNameSplit = config.ConfigurationName.Split("."); - var existingConfigs = Directory.EnumerateFiles( - configBackupFolder, - configNameSplit[0] + "*") - .Select(c => new FileInfo(c)) - .OrderByDescending(c => c.LastWriteTime).ToList(); - if (existingConfigs.Skip(10).Any()) + try { - foreach (var oldBak in existingConfigs.Skip(10).ToList()) - { - oldBak.Delete(); - } - } + var configBackupFolder = Path.Join(configDir, BackupFolder); + if (!Directory.Exists(configBackupFolder)) + Directory.CreateDirectory(configBackupFolder); - string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); - _logger.LogTrace("Backing up current config to {backupPath}", backupPath); - File.Copy(config.ConfigurationPath, backupPath, overwrite: true); - FileInfo fi = new(backupPath); - fi.LastWriteTimeUtc = DateTime.UtcNow; - } - catch (Exception ex) - { - // ignore if file cannot be backupped - _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + var configNameSplit = config.ConfigurationName.Split("."); + var existingConfigs = Directory.EnumerateFiles( + configBackupFolder, + configNameSplit[0] + "*") + .Select(c => new FileInfo(c)) + .OrderByDescending(c => c.LastWriteTime).ToList(); + if (existingConfigs.Skip(10).Any()) + { + foreach (var oldBak in existingConfigs.Skip(10).ToList()) + { + oldBak.Delete(); + } + } + + string backupPath = Path.Combine(configBackupFolder, configNameSplit[0] + "." + DateTime.Now.ToString("yyyyMMddHHmmss") + "." + configNameSplit[1]); + _logger.LogTrace("Backing up current config to {backupPath}", backupPath); + File.Copy(config.ConfigurationPath, backupPath, overwrite: true); + FileInfo fi = new(backupPath); + fi.LastWriteTimeUtc = DateTime.UtcNow; + } + catch (Exception ex) + { + // ignore if file cannot be backupped + _logger.LogWarning(ex, "Could not create backup for {config}", config.ConfigurationPath); + } } var temp = config.ConfigurationPath + ".tmp"; @@ -110,7 +114,7 @@ public class ConfigurationSaveService : IHostedService { await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(config.Current, typeof(T), new JsonSerializerOptions() { - WriteIndented = true + WriteIndented = !isTempCollections })).ConfigureAwait(false); File.Move(temp, config.ConfigurationPath, true); config.UpdateLastWriteTime(); diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 4a2b2c9..c68f9aa 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -160,8 +160,6 @@ public class LightlessConfig : ILightlessConfiguration public string? SelectedFinderSyncshell { get; set; } = null; public string LastSeenVersion { get; set; } = string.Empty; public bool EnableParticleEffects { get; set; } = true; - public HashSet OrphanableTempCollections { get; set; } = []; - public List OrphanableTempCollectionEntries { get; set; } = []; public AnimationValidationMode AnimationValidationMode { get; set; } = AnimationValidationMode.Unsafe; public bool AnimationAllowOneBasedShift { get; set; } = false; public bool AnimationAllowNeighborIndexTolerance { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs new file mode 100644 index 0000000..5f16e61 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/TempCollectionConfig.cs @@ -0,0 +1,10 @@ +using LightlessSync.LightlessConfiguration.Models; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public sealed class TempCollectionConfig : ILightlessConfiguration +{ + public int Version { get; set; } = 1; + public List OrphanableTempCollectionEntries { get; set; } = []; +} diff --git a/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs new file mode 100644 index 0000000..a58a883 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/TempCollectionConfigService.cs @@ -0,0 +1,12 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.LightlessConfiguration; + +public sealed class TempCollectionConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "tempcollections.json"; + + public TempCollectionConfigService(string configDir) : base(configDir) { } + + public override string ConfigurationName => ConfigName; +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index f14aeda..5affe17 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -429,6 +429,7 @@ public sealed class Plugin : IDalamudPlugin LightlessSync.UI.Style.MainStyle.Init(cfg, theme); return cfg; }); + services.AddSingleton(sp => new TempCollectionConfigService(configDir)); services.AddSingleton(sp => new ServerConfigService(configDir)); services.AddSingleton(sp => new NotesConfigService(configDir)); services.AddSingleton(sp => new PairTagConfigService(configDir)); @@ -442,6 +443,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); services.AddSingleton>(sp => sp.GetRequiredService()); diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index 55b511c..4fcdbc0 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -1343,22 +1343,11 @@ internal static class MdlDecimator return false; } - return IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); + return ModelDecimationFilters.IsBodyMaterial(mdl.Materials[mesh.MaterialIndex]); } private static bool IsBodyMaterial(string materialPath) - { - if (string.IsNullOrWhiteSpace(materialPath)) - { - return false; - } - - var normalized = materialPath.Replace('\\', '/').ToLowerInvariant(); - var nameStart = normalized.LastIndexOf('/'); - var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; - return fileName.Contains("_bibo", StringComparison.Ordinal) - || fileName.EndsWith("_a.mtrl", StringComparison.Ordinal); - } + => ModelDecimationFilters.IsBodyMaterial(materialPath); private sealed class BodyCollisionData { diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs new file mode 100644 index 0000000..f2d7f8a --- /dev/null +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationFilters.cs @@ -0,0 +1,132 @@ +namespace LightlessSync.Services.ModelDecimation; + +internal static class ModelDecimationFilters +{ + // MODELS ONLY HERE, NOT MATERIALS + internal static readonly string[] HairPaths = + [ + "/hair/", + "hir.mdl", + ]; + + internal static readonly string[] ClothingPaths = + [ + "chara/equipment/", + "/equipment/", + + "met.mdl", + "top.mdl", + "glv.mdl", + "dwn.mdl", + "sho.mdl", + ]; + + internal static readonly string[] AccessoryPaths = + [ + "/accessory/", + "chara/accessory/", + + "ear.mdl", + "nek.mdl", + "wrs.mdl", + "ril.mdl", + "rir.mdl", + ]; + + internal static readonly string[] BodyPaths = + [ + "/body/", + "chara/equipment/e0000/model/", + "chara/equipment/e9903/model/", + "chara/equipment/e9903/model/", + "chara/equipment/e0279/model/", + ]; + + internal static readonly string[] FaceHeadPaths = + [ + "/face/", + "/obj/face/", + "/head/", + "fac.mdl", + ]; + + internal static readonly string[] TailOrEarPaths = + [ + "/tail/", + "/obj/tail/", + "/zear/", + "/obj/zear/", + + "til.mdl", + "zer.mdl", + ]; + + // BODY MATERIALS ONLY, NOT MESHES + internal static readonly string[] BodyMaterials = + [ + "b0001_bibo.mtrl", + "b0101_bibo.mtrl", + + "b0001_a.mtrl", + "b0001_b.mtrl", + + "b0101_a.mtrl", + "b0101_b.mtrl", + ]; + + internal static string NormalizePath(string path) + => path.Replace('\\', '/').ToLowerInvariant(); + + internal static bool IsHairPath(string normalizedPath) + => ContainsAny(normalizedPath, HairPaths); + + internal static bool IsClothingPath(string normalizedPath) + => ContainsAny(normalizedPath, ClothingPaths); + + internal static bool IsAccessoryPath(string normalizedPath) + => ContainsAny(normalizedPath, AccessoryPaths); + + + internal static bool IsBodyPath(string normalizedPath) + => ContainsAny(normalizedPath, BodyPaths); + + internal static bool IsFaceHeadPath(string normalizedPath) + => ContainsAny(normalizedPath, FaceHeadPaths); + + internal static bool IsTailOrEarPath(string normalizedPath) + => ContainsAny(normalizedPath, TailOrEarPaths); + + internal static bool ContainsAny(string normalizedPath, IReadOnlyList markers) + { + for (var i = 0; i < markers.Count; i++) + { + if (normalizedPath.Contains(markers[i], StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + internal static bool IsBodyMaterial(string materialPath) + { + if (string.IsNullOrWhiteSpace(materialPath)) + { + return false; + } + + var normalized = NormalizePath(materialPath); + var nameStart = normalized.LastIndexOf('/'); + var fileName = nameStart >= 0 ? normalized[(nameStart + 1)..] : normalized; + foreach (var marker in BodyMaterials) + { + if (fileName.Contains(marker, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index 00406f6..3caa070 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -348,46 +348,40 @@ public sealed class ModelDecimationService return true; } - var normalized = NormalizeGamePath(gamePath); - if (normalized.Contains("/hair/", StringComparison.Ordinal)) + var normalized = ModelDecimationFilters.NormalizePath(gamePath); + if (ModelDecimationFilters.IsHairPath(normalized)) { return false; } - if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsClothingPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowClothing; } - if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsAccessoryPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowAccessories; } - if (normalized.Contains("/chara/human/", StringComparison.Ordinal)) + if (ModelDecimationFilters.IsBodyPath(normalized)) { - if (normalized.Contains("/body/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowBody; - } + return _performanceConfigService.Current.ModelDecimationAllowBody; + } - if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowFaceHead; - } + if (ModelDecimationFilters.IsFaceHeadPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowFaceHead; + } - if (normalized.Contains("/tail/", StringComparison.Ordinal)) - { - return _performanceConfigService.Current.ModelDecimationAllowTail; - } + if (ModelDecimationFilters.IsTailOrEarPath(normalized)) + { + return _performanceConfigService.Current.ModelDecimationAllowTail; } return true; } - private static string NormalizeGamePath(string path) - => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out ModelDecimationSettings settings) { settings = new ModelDecimationSettings( diff --git a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs index 87d37ac..6f664de 100644 --- a/LightlessSync/Services/PenumbraTempCollectionJanitor.cs +++ b/LightlessSync/Services/PenumbraTempCollectionJanitor.cs @@ -10,15 +10,18 @@ namespace LightlessSync.Services; public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriberBase { private readonly IpcManager _ipc; - private readonly LightlessConfigService _config; + private readonly TempCollectionConfigService _config; + private readonly CancellationTokenSource _cleanupCts = new(); private int _ran; + private const int CleanupBatchSize = 50; + private static readonly TimeSpan CleanupBatchDelay = TimeSpan.FromMilliseconds(50); private static readonly TimeSpan OrphanCleanupDelay = TimeSpan.FromDays(1); public PenumbraTempCollectionJanitor( ILogger logger, LightlessMediator mediator, IpcManager ipc, - LightlessConfigService config) : base(logger, mediator) + TempCollectionConfigService config) : base(logger, mediator) { _ipc = ipc; _config = config; @@ -31,10 +34,6 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (id == Guid.Empty) return; var changed = false; var config = _config.Current; - if (config.OrphanableTempCollections.Add(id)) - { - changed = true; - } var now = DateTime.UtcNow; var existing = config.OrphanableTempCollectionEntries.FirstOrDefault(entry => entry.Id == id); @@ -63,8 +62,7 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber { if (id == Guid.Empty) return; var config = _config.Current; - var changed = config.OrphanableTempCollections.Remove(id); - changed |= RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; + var changed = RemoveEntry(config.OrphanableTempCollectionEntries, id) > 0; if (changed) { _config.Save(); @@ -79,14 +77,31 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber if (!_ipc.Penumbra.APIAvailable) return; + _ = Task.Run(async () => + { + try + { + await CleanupOrphansOnBootAsync(_cleanupCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Logger.LogError(ex, "Error cleaning orphaned temp collections"); + } + }); + } + + private async Task CleanupOrphansOnBootAsync(CancellationToken token) + { var config = _config.Current; - var ids = config.OrphanableTempCollections; var entries = config.OrphanableTempCollectionEntries; - if (ids.Count == 0 && entries.Count == 0) + if (entries.Count == 0) return; var now = DateTime.UtcNow; - var changed = EnsureEntries(ids, entries, now); + var changed = EnsureEntryTimes(entries, now); var cutoff = now - OrphanCleanupDelay; var expired = entries .Where(entry => entry.Id != Guid.Empty && entry.RegisteredAtUtc != DateTime.MinValue && entry.RegisteredAtUtc <= cutoff) @@ -105,25 +120,47 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber var appId = Guid.NewGuid(); Logger.LogInformation("Cleaning up {count} orphaned Lightless temp collections older than {delay}", expired.Count, OrphanCleanupDelay); + List removedIds = []; foreach (var id in expired) { + if (token.IsCancellationRequested) + { + break; + } + try { - _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id) - .GetAwaiter().GetResult(); + await _ipc.Penumbra.RemoveTemporaryCollectionAsync(Logger, appId, id).ConfigureAwait(false); } catch (Exception ex) { Logger.LogDebug(ex, "Failed removing orphaned temp collection {id}", id); } + + removedIds.Add(id); + if (removedIds.Count % CleanupBatchSize == 0) + { + try + { + await Task.Delay(CleanupBatchDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } } - foreach (var id in expired) + if (removedIds.Count == 0) { - ids.Remove(id); + if (changed) + { + _config.Save(); + } + return; } - foreach (var id in expired) + foreach (var id in removedIds) { RemoveEntry(entries, id); } @@ -131,6 +168,17 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber _config.Save(); } + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cleanupCts.Cancel(); + _cleanupCts.Dispose(); + } + + base.Dispose(disposing); + } + private static int RemoveEntry(List entries, Guid id) { var removed = 0; @@ -148,29 +196,9 @@ public sealed class PenumbraTempCollectionJanitor : DisposableMediatorSubscriber return removed; } - private static bool EnsureEntries(HashSet ids, List entries, DateTime now) + private static bool EnsureEntryTimes(List entries, DateTime now) { var changed = false; - foreach (var id in ids) - { - if (id == Guid.Empty) - { - continue; - } - - if (entries.Any(entry => entry.Id == id)) - { - continue; - } - - entries.Add(new OrphanableTempCollectionEntry - { - Id = id, - RegisteredAtUtc = now - }); - changed = true; - } - foreach (var entry in entries) { if (entry.Id == Guid.Empty || entry.RegisteredAtUtc != DateTime.MinValue)