diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 09854fde6..d2c7c269b 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -3,14 +3,16 @@ using System.Threading; using System.Threading.Tasks; using Hangfire; +using Hangfire.Console; +using Hangfire.Server; +using Humanizer; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; -using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; -using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.NexusExport; @@ -108,19 +110,19 @@ public Task StartAsync(CancellationToken cancellationToken) bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient; // set startup tasks - BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync()); + BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync(null)); if (enableCurseForgeExport) - BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync()); + BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync(null)); if (enableNexusExport) - BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync()); + BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null)); BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); // set recurring tasks - RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes + RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(null), "*/10 * * * *"); // every 10 minutes if (enableCurseForgeExport) - RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(), "*/10 * * * *"); + RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(null), "*/10 * * * *"); if (enableNexusExport) - RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(), "*/10 * * * *"); + RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(null), "*/10 * * * *"); RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc) BackgroundService.IsStarted = true; @@ -150,54 +152,48 @@ public void Dispose() ** Tasks ****/ /// Update the cached wiki metadata. - [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] - public static async Task UpdateWikiAsync() + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateWikiAsync(PerformContext? context) { if (!BackgroundService.IsStarted) throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + context.WriteLine("Fetching data from wiki..."); WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); + + context.WriteLine("Saving data..."); BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); + + context.WriteLine("Done!"); } /// Update the cached CurseForge mod dump. - [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] - public static async Task UpdateCurseForgeExportAsync() + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateCurseForgeExportAsync(PerformContext? context) { - if (!BackgroundService.IsStarted) - throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); - - var cache = BackgroundService.CurseForgeExportCache; - var client = BackgroundService.CurseForgeExportApiClient; - - if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge)) - { - CurseForgeFullExport data = await client.FetchExportAsync(); - cache.SetData(data); - } - - if (cache.IsStale(BackgroundService.ExportStaleAge)) - cache.SetData(null); // if the export is too old, fetch fresh mod data from the API instead + await UpdateExportAsync( + context, + BackgroundService.CurseForgeExportCache!, + BackgroundService.CurseForgeExportApiClient!, + client => client.FetchLastModifiedDateAsync(), + async (cache, client) => cache.SetData(await client.FetchExportAsync()) + ); } /// Update the cached Nexus mod dump. - [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] - public static async Task UpdateNexusExportAsync() + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])] + public static async Task UpdateNexusExportAsync(PerformContext? context) { - if (!BackgroundService.IsStarted) - throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); - - var cache = BackgroundService.NexusExportCache; - var client = BackgroundService.NexusExportApiClient; - - if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge)) - { - NexusFullExport data = await client.FetchExportAsync(); - cache.SetData(data); - } - - if (cache.IsStale(BackgroundService.ExportStaleAge)) - cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead + await UpdateExportAsync( + context, + BackgroundService.NexusExportCache!, + BackgroundService.NexusExportApiClient!, + client => client.FetchLastModifiedDateAsync(), + async (cache, client) => cache.SetData(await client.FetchExportAsync()) + ); } /// Remove mods which haven't been requested in over 48 hours. @@ -209,10 +205,6 @@ public static Task RemoveStaleModsAsync() // remove mods in mod cache BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); - // remove stale export cache - if (BackgroundService.NexusExportCache.IsStale(BackgroundService.ExportStaleAge)) - BackgroundService.NexusExportCache.SetData(null); - return Task.CompletedTask; } @@ -229,5 +221,74 @@ private void TryInit() BackgroundService.JobServer = new BackgroundJobServer(); } + + /// Update the cached mods export for a site. + /// The export cache repository type. + /// The export API client. + /// Information about the context in which the job is performed. This is injected automatically by Hangfire. + /// The export cache to update. + /// The export API with which to fetch data from the remote API. + /// Fetch the date when the export on the server was last modified. + /// Fetch the latest export file from the Nexus Mods export API. + /// The method wasn't called before running this task. + private static async Task UpdateExportAsync(PerformContext? context, TCacheRepository cache, TExportApiClient client, Func> fetchLastModifiedDateAsync, Func fetchDataAsync) + where TCacheRepository : IExportCacheRepository + { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + + // refresh data + context.WriteLine("Checking if we can refresh the data..."); + if (BackgroundService.CanRefreshFromExportApi(await fetchLastModifiedDateAsync(client), cache, out string? failReason)) + { + context.WriteLine("Fetching data..."); + await fetchDataAsync(cache, client); + context.WriteLine($"Cache updated. The data was last modified {BackgroundService.FormatDateModified(cache.GetLastModified())}."); + } + else + context.WriteLine($"Skipped data fetch: {failReason}."); + + // clear if stale + if (cache.IsStale(BackgroundService.ExportStaleAge)) + { + context.WriteLine("The cached data is stale, clearing cache..."); + cache.Clear(); + } + + context.WriteLine("Done!"); + } + + /// Get whether newer non-stale data can be fetched from the server. + /// The last-modified data from the remote API. + /// The repository to update. + /// The reason to log if we can't fetch data. + private static bool CanRefreshFromExportApi(DateTimeOffset serverModified, IExportCacheRepository repository, [NotNullWhen(false)] out string? failReason) + { + if (repository.IsStale(serverModified, BackgroundService.ExportStaleAge)) + { + failReason = $"server was last modified {BackgroundService.FormatDateModified(serverModified)}, which exceeds the {BackgroundService.ExportStaleAge}-minute-stale limit"; + return false; + } + + if (repository.IsLoaded()) + { + DateTimeOffset localModified = repository.GetLastModified(); + if (localModified >= serverModified) + { + failReason = $"server was last modified {BackgroundService.FormatDateModified(serverModified)}, which {(serverModified == localModified ? "matches our cached data" : $"is older than our cached {BackgroundService.FormatDateModified(localModified)}")}"; + return false; + } + } + + failReason = null; + return true; + } + + /// Format a 'date modified' value for the task logs. + /// The date to log. + private static string FormatDateModified(DateTimeOffset date) + { + return $"{date:O} (age: {(DateTimeOffset.UtcNow - date).Humanize()})"; + } } } diff --git a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs index f5354b939..a2ae1bbab 100644 --- a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs @@ -3,14 +3,12 @@ namespace StardewModdingAPI.Web.Framework.Caching { /// The base logic for a cache repository. - internal abstract class BaseCacheRepository + internal abstract class BaseCacheRepository : ICacheRepository { /********* ** Public methods *********/ - /// Whether cached data is stale. - /// The date when the data was updated. - /// The age in minutes before data is considered stale. + /// public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes) { return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes); diff --git a/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs new file mode 100644 index 000000000..22de31e36 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/BaseExportCacheRepository.cs @@ -0,0 +1,26 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// The base logic for an export cache repository. + internal abstract class BaseExportCacheRepository : BaseCacheRepository, IExportCacheRepository + { + /********* + ** Public methods + *********/ + /// + public abstract bool IsLoaded(); + + /// + public abstract DateTimeOffset GetLastModified(); + + /// + public bool IsStale(int staleMinutes) + { + return this.IsStale(this.GetLastModified(), staleMinutes); + } + + /// + public abstract void Clear(); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs index 6fe80425a..da7046ec3 100644 --- a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/CurseForgeExportCacheMemoryRepository.cs @@ -1,13 +1,11 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport { /// Manages cached mod data from the CurseForge export API in-memory. - internal class CurseForgeExportCacheMemoryRepository : BaseCacheRepository, ICurseForgeExportCacheRepository + internal class CurseForgeExportCacheMemoryRepository : BaseExportCacheRepository, ICurseForgeExportCacheRepository { /********* ** Fields @@ -21,22 +19,21 @@ internal class CurseForgeExportCacheMemoryRepository : BaseCacheRepository, ICur *********/ /// [MemberNotNullWhen(true, nameof(CurseForgeExportCacheMemoryRepository.Data))] - public bool IsLoaded() + public override bool IsLoaded() { return this.Data?.Mods.Count > 0; } /// - public async Task CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes) + public override DateTimeOffset GetLastModified() { - DateTimeOffset serverLastModified = await client.FetchLastModifiedDateAsync(); + return this.Data?.LastModified ?? DateTimeOffset.MinValue; + } - return - !this.IsStale(serverLastModified, staleMinutes) - && ( - !this.IsLoaded() - || this.Data.LastModified < serverLastModified - ); + /// + public override void Clear() + { + this.SetData(null); } /// @@ -58,13 +55,5 @@ public void SetData(CurseForgeFullExport? export) { this.Data = export; } - - /// - public bool IsStale(int staleMinutes) - { - return - this.Data is null - || this.IsStale(this.Data.LastModified, staleMinutes); - } } } diff --git a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs index 5027217ec..b3a46ec80 100644 --- a/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/CurseForgeExport/ICurseForgeExportCacheRepository.cs @@ -1,24 +1,14 @@ using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport; using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels; namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport { /// Manages cached mod data from the CurseForge export API. - internal interface ICurseForgeExportCacheRepository : ICacheRepository + internal interface ICurseForgeExportCacheRepository : IExportCacheRepository { /********* ** Methods *********/ - /// Get whether the export data is currently available. - bool IsLoaded(); - - /// Get whether newer non-stale data can be fetched from the server. - /// The CurseForge API client. - /// The age in minutes before data is considered stale. - Task CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes); - /// Get the cached data for a mod, if it exists in the export. /// The CurseForge mod ID. /// The fetched metadata. @@ -27,9 +17,5 @@ internal interface ICurseForgeExportCacheRepository : ICacheRepository /// Set the cached data to use. /// The export received from the CurseForge Mods API, or null to remove it. void SetData(CurseForgeFullExport? export); - - /// Get whether the cached data is stale. - /// The age in minutes before data is considered stale. - bool IsStale(int staleMinutes); } } diff --git a/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs new file mode 100644 index 000000000..e61fe331c --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/IExportCacheRepository.cs @@ -0,0 +1,24 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// Encapsulates logic for accessing data in a cached mod export from a remote API. + internal interface IExportCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// Get whether the export data is currently available. + bool IsLoaded(); + + /// Get the date when the cached data was last modified. + DateTimeOffset GetLastModified(); + + /// Get whether the cached data is stale. + /// The age in minutes before data is considered stale. + bool IsStale(int staleMinutes); + + /// Clear all data in the cache. + void Clear(); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs index 5b12dad66..08d7568d7 100644 --- a/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/INexusExportCacheRepository.cs @@ -1,24 +1,14 @@ using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; namespace StardewModdingAPI.Web.Framework.Caching.NexusExport { /// Manages cached mod data from the Nexus export API. - internal interface INexusExportCacheRepository : ICacheRepository + internal interface INexusExportCacheRepository : IExportCacheRepository { /********* ** Methods *********/ - /// Get whether the export data is currently available. - bool IsLoaded(); - - /// Get whether newer non-stale data can be fetched from the server. - /// The Nexus API client. - /// The age in minutes before data is considered stale. - Task CanRefreshFromAsync(INexusExportApiClient client, int staleMinutes); - /// Get the cached data for a mod, if it exists in the export. /// The Nexus mod ID. /// The fetched metadata. @@ -27,9 +17,5 @@ internal interface INexusExportCacheRepository : ICacheRepository /// Set the cached data to use. /// The export received from the Nexus Mods API, or null to remove it. void SetData(NexusFullExport? export); - - /// Get whether the cached data is stale. - /// The age in minutes before data is considered stale. - bool IsStale(int staleMinutes); } } diff --git a/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs index d80f48204..194882b55 100644 --- a/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/NexusExport/NexusExportCacheMemoryRepository.cs @@ -1,13 +1,11 @@ using System; using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport; using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels; namespace StardewModdingAPI.Web.Framework.Caching.NexusExport { /// Manages cached mod data from the Nexus export API in-memory. - internal class NexusExportCacheMemoryRepository : BaseCacheRepository, INexusExportCacheRepository + internal class NexusExportCacheMemoryRepository : BaseExportCacheRepository, INexusExportCacheRepository { /********* ** Fields @@ -21,22 +19,21 @@ internal class NexusExportCacheMemoryRepository : BaseCacheRepository, INexusExp *********/ /// [MemberNotNullWhen(true, nameof(NexusExportCacheMemoryRepository.Data))] - public bool IsLoaded() + public override bool IsLoaded() { return this.Data?.Data.Count > 0; } /// - public async Task CanRefreshFromAsync(INexusExportApiClient client, int staleMinutes) + public override DateTimeOffset GetLastModified() { - DateTimeOffset serverLastModified = await client.FetchLastModifiedDateAsync(); + return this.Data?.LastUpdated ?? DateTimeOffset.MinValue; + } - return - !this.IsStale(serverLastModified, staleMinutes) - && ( - !this.IsLoaded() - || this.Data.LastUpdated < serverLastModified - ); + /// + public override void Clear() + { + this.SetData(null); } /// @@ -58,12 +55,5 @@ public void SetData(NexusFullExport? export) { this.Data = export; } - - /// - public bool IsStale(int staleMinutes) - { - DateTimeOffset? lastUpdated = this.Data?.LastUpdated; - return lastUpdated.HasValue && this.IsStale(lastUpdated.Value, staleMinutes); - } } } diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index b151e9dc1..00c833237 100644 --- a/src/SMAPI.Web/SMAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -17,6 +17,7 @@ + diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index e7674c3ce..a66fef153 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net; using Hangfire; +using Hangfire.Console; using Hangfire.MemoryStorage; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -95,7 +96,8 @@ public void ConfigureServices(IServiceCollection services) .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() - .UseMemoryStorage(); + .UseMemoryStorage() + .UseConsole(); }); // init background service