Skip to content

Commit 3793d2e

Browse files
committed
add Hangfire task logging
1 parent b2c2a1c commit 3793d2e

10 files changed

+183
-120
lines changed

src/SMAPI.Web/BackgroundService.cs

Lines changed: 107 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
using System.Threading;
44
using System.Threading.Tasks;
55
using Hangfire;
6+
using Hangfire.Console;
7+
using Hangfire.Server;
8+
using Humanizer;
69
using Microsoft.Extensions.Hosting;
710
using Microsoft.Extensions.Options;
811
using StardewModdingAPI.Toolkit;
912
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
10-
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
1113
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
12-
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport.ResponseModels;
1314
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
15+
using StardewModdingAPI.Web.Framework.Caching;
1416
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
1517
using StardewModdingAPI.Web.Framework.Caching.Mods;
1618
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
@@ -108,19 +110,19 @@ public Task StartAsync(CancellationToken cancellationToken)
108110
bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient;
109111

110112
// set startup tasks
111-
BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync());
113+
BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync(null));
112114
if (enableCurseForgeExport)
113-
BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync());
115+
BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync(null));
114116
if (enableNexusExport)
115-
BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync());
117+
BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null));
116118
BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync());
117119

118120
// set recurring tasks
119-
RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes
121+
RecurringJob.AddOrUpdate("update wiki data", () => BackgroundService.UpdateWikiAsync(null), "*/10 * * * *"); // every 10 minutes
120122
if (enableCurseForgeExport)
121-
RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(), "*/10 * * * *");
123+
RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(null), "*/10 * * * *");
122124
if (enableNexusExport)
123-
RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(), "*/10 * * * *");
125+
RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(null), "*/10 * * * *");
124126
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)
125127

126128
BackgroundService.IsStarted = true;
@@ -150,54 +152,48 @@ public void Dispose()
150152
** Tasks
151153
****/
152154
/// <summary>Update the cached wiki metadata.</summary>
153-
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
154-
public static async Task UpdateWikiAsync()
155+
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
156+
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
157+
public static async Task UpdateWikiAsync(PerformContext? context)
155158
{
156159
if (!BackgroundService.IsStarted)
157160
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
158161

162+
context.WriteLine("Fetching data from wiki...");
159163
WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync();
164+
165+
context.WriteLine("Saving data...");
160166
BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods);
167+
168+
context.WriteLine("Done!");
161169
}
162170

163171
/// <summary>Update the cached CurseForge mod dump.</summary>
164-
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
165-
public static async Task UpdateCurseForgeExportAsync()
172+
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
173+
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
174+
public static async Task UpdateCurseForgeExportAsync(PerformContext? context)
166175
{
167-
if (!BackgroundService.IsStarted)
168-
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
169-
170-
var cache = BackgroundService.CurseForgeExportCache;
171-
var client = BackgroundService.CurseForgeExportApiClient;
172-
173-
if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge))
174-
{
175-
CurseForgeFullExport data = await client.FetchExportAsync();
176-
cache.SetData(data);
177-
}
178-
179-
if (cache.IsStale(BackgroundService.ExportStaleAge))
180-
cache.SetData(null); // if the export is too old, fetch fresh mod data from the API instead
176+
await UpdateExportAsync(
177+
context,
178+
BackgroundService.CurseForgeExportCache!,
179+
BackgroundService.CurseForgeExportApiClient!,
180+
client => client.FetchLastModifiedDateAsync(),
181+
async (cache, client) => cache.SetData(await client.FetchExportAsync())
182+
);
181183
}
182184

183185
/// <summary>Update the cached Nexus mod dump.</summary>
184-
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
185-
public static async Task UpdateNexusExportAsync()
186+
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
187+
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
188+
public static async Task UpdateNexusExportAsync(PerformContext? context)
186189
{
187-
if (!BackgroundService.IsStarted)
188-
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
189-
190-
var cache = BackgroundService.NexusExportCache;
191-
var client = BackgroundService.NexusExportApiClient;
192-
193-
if (await cache.CanRefreshFromAsync(client, BackgroundService.ExportStaleAge))
194-
{
195-
NexusFullExport data = await client.FetchExportAsync();
196-
cache.SetData(data);
197-
}
198-
199-
if (cache.IsStale(BackgroundService.ExportStaleAge))
200-
cache.SetData(null); // if the export is too old, fetch fresh mod data from the site/API instead
190+
await UpdateExportAsync(
191+
context,
192+
BackgroundService.NexusExportCache!,
193+
BackgroundService.NexusExportApiClient!,
194+
client => client.FetchLastModifiedDateAsync(),
195+
async (cache, client) => cache.SetData(await client.FetchExportAsync())
196+
);
201197
}
202198

203199
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
@@ -209,10 +205,6 @@ public static Task RemoveStaleModsAsync()
209205
// remove mods in mod cache
210206
BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48));
211207

212-
// remove stale export cache
213-
if (BackgroundService.NexusExportCache.IsStale(BackgroundService.ExportStaleAge))
214-
BackgroundService.NexusExportCache.SetData(null);
215-
216208
return Task.CompletedTask;
217209
}
218210

@@ -229,5 +221,74 @@ private void TryInit()
229221

230222
BackgroundService.JobServer = new BackgroundJobServer();
231223
}
224+
225+
/// <summary>Update the cached mods export for a site.</summary>
226+
/// <typeparam name="TCacheRepository">The export cache repository type.</typeparam>
227+
/// <typeparam name="TExportApiClient">The export API client.</typeparam>
228+
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
229+
/// <param name="cache">The export cache to update.</param>
230+
/// <param name="client">The export API with which to fetch data from the remote API.</param>
231+
/// <param name="fetchLastModifiedDateAsync">Fetch the date when the export on the server was last modified.</param>
232+
/// <param name="fetchDataAsync">Fetch the latest export file from the Nexus Mods export API.</param>
233+
/// <exception cref="InvalidOperationException">The <see cref="StartAsync"/> method wasn't called before running this task.</exception>
234+
private static async Task UpdateExportAsync<TCacheRepository, TExportApiClient>(PerformContext? context, TCacheRepository cache, TExportApiClient client, Func<TExportApiClient, Task<DateTimeOffset>> fetchLastModifiedDateAsync, Func<TCacheRepository, TExportApiClient, Task> fetchDataAsync)
235+
where TCacheRepository : IExportCacheRepository
236+
{
237+
if (!BackgroundService.IsStarted)
238+
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
239+
240+
// refresh data
241+
context.WriteLine("Checking if we can refresh the data...");
242+
if (BackgroundService.CanRefreshFromExportApi(await fetchLastModifiedDateAsync(client), cache, out string? failReason))
243+
{
244+
context.WriteLine("Fetching data...");
245+
await fetchDataAsync(cache, client);
246+
context.WriteLine($"Cache updated. The data was last modified {BackgroundService.FormatDateModified(cache.GetLastModified())}.");
247+
}
248+
else
249+
context.WriteLine($"Skipped data fetch: {failReason}.");
250+
251+
// clear if stale
252+
if (cache.IsStale(BackgroundService.ExportStaleAge))
253+
{
254+
context.WriteLine("The cached data is stale, clearing cache...");
255+
cache.Clear();
256+
}
257+
258+
context.WriteLine("Done!");
259+
}
260+
261+
/// <summary>Get whether newer non-stale data can be fetched from the server.</summary>
262+
/// <param name="serverModified">The last-modified data from the remote API.</param>
263+
/// <param name="repository">The repository to update.</param>
264+
/// <param name="failReason">The reason to log if we can't fetch data.</param>
265+
private static bool CanRefreshFromExportApi(DateTimeOffset serverModified, IExportCacheRepository repository, [NotNullWhen(false)] out string? failReason)
266+
{
267+
if (repository.IsStale(serverModified, BackgroundService.ExportStaleAge))
268+
{
269+
failReason = $"server was last modified {BackgroundService.FormatDateModified(serverModified)}, which exceeds the {BackgroundService.ExportStaleAge}-minute-stale limit";
270+
return false;
271+
}
272+
273+
if (repository.IsLoaded())
274+
{
275+
DateTimeOffset localModified = repository.GetLastModified();
276+
if (localModified >= serverModified)
277+
{
278+
failReason = $"server was last modified {BackgroundService.FormatDateModified(serverModified)}, which {(serverModified == localModified ? "matches our cached data" : $"is older than our cached {BackgroundService.FormatDateModified(localModified)}")}";
279+
return false;
280+
}
281+
}
282+
283+
failReason = null;
284+
return true;
285+
}
286+
287+
/// <summary>Format a 'date modified' value for the task logs.</summary>
288+
/// <param name="date">The date to log.</param>
289+
private static string FormatDateModified(DateTimeOffset date)
290+
{
291+
return $"{date:O} (age: {(DateTimeOffset.UtcNow - date).Humanize()})";
292+
}
232293
}
233294
}

src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@
33
namespace StardewModdingAPI.Web.Framework.Caching
44
{
55
/// <summary>The base logic for a cache repository.</summary>
6-
internal abstract class BaseCacheRepository
6+
internal abstract class BaseCacheRepository : ICacheRepository
77
{
88
/*********
99
** Public methods
1010
*********/
11-
/// <summary>Whether cached data is stale.</summary>
12-
/// <param name="lastUpdated">The date when the data was updated.</param>
13-
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
11+
/// <inheritdoc />
1412
public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes)
1513
{
1614
return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using System;
2+
3+
namespace StardewModdingAPI.Web.Framework.Caching
4+
{
5+
/// <summary>The base logic for an export cache repository.</summary>
6+
internal abstract class BaseExportCacheRepository : BaseCacheRepository, IExportCacheRepository
7+
{
8+
/*********
9+
** Public methods
10+
*********/
11+
/// <inheritdoc />
12+
public abstract bool IsLoaded();
13+
14+
/// <inheritdoc />
15+
public abstract DateTimeOffset GetLastModified();
16+
17+
/// <inheritdoc />
18+
public bool IsStale(int staleMinutes)
19+
{
20+
return this.IsStale(this.GetLastModified(), staleMinutes);
21+
}
22+
23+
/// <inheritdoc />
24+
public abstract void Clear();
25+
}
26+
}
Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
using System;
22
using System.Diagnostics.CodeAnalysis;
3-
using System.Threading.Tasks;
4-
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
53
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
64

75
namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport
86
{
97
/// <summary>Manages cached mod data from the CurseForge export API in-memory.</summary>
10-
internal class CurseForgeExportCacheMemoryRepository : BaseCacheRepository, ICurseForgeExportCacheRepository
8+
internal class CurseForgeExportCacheMemoryRepository : BaseExportCacheRepository, ICurseForgeExportCacheRepository
119
{
1210
/*********
1311
** Fields
@@ -21,22 +19,21 @@ internal class CurseForgeExportCacheMemoryRepository : BaseCacheRepository, ICur
2119
*********/
2220
/// <inheritdoc />
2321
[MemberNotNullWhen(true, nameof(CurseForgeExportCacheMemoryRepository.Data))]
24-
public bool IsLoaded()
22+
public override bool IsLoaded()
2523
{
2624
return this.Data?.Mods.Count > 0;
2725
}
2826

2927
/// <inheritdoc />
30-
public async Task<bool> CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes)
28+
public override DateTimeOffset GetLastModified()
3129
{
32-
DateTimeOffset serverLastModified = await client.FetchLastModifiedDateAsync();
30+
return this.Data?.LastModified ?? DateTimeOffset.MinValue;
31+
}
3332

34-
return
35-
!this.IsStale(serverLastModified, staleMinutes)
36-
&& (
37-
!this.IsLoaded()
38-
|| this.Data.LastModified < serverLastModified
39-
);
33+
/// <inheritdoc />
34+
public override void Clear()
35+
{
36+
this.SetData(null);
4037
}
4138

4239
/// <inheritdoc />
@@ -58,13 +55,5 @@ public void SetData(CurseForgeFullExport? export)
5855
{
5956
this.Data = export;
6057
}
61-
62-
/// <inheritdoc />
63-
public bool IsStale(int staleMinutes)
64-
{
65-
return
66-
this.Data is null
67-
|| this.IsStale(this.Data.LastModified, staleMinutes);
68-
}
6958
}
7059
}
Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
11
using System.Diagnostics.CodeAnalysis;
2-
using System.Threading.Tasks;
3-
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
42
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport.ResponseModels;
53

64
namespace StardewModdingAPI.Web.Framework.Caching.CurseForgeExport
75
{
86
/// <summary>Manages cached mod data from the CurseForge export API.</summary>
9-
internal interface ICurseForgeExportCacheRepository : ICacheRepository
7+
internal interface ICurseForgeExportCacheRepository : IExportCacheRepository
108
{
119
/*********
1210
** Methods
1311
*********/
14-
/// <summary>Get whether the export data is currently available.</summary>
15-
bool IsLoaded();
16-
17-
/// <summary>Get whether newer non-stale data can be fetched from the server.</summary>
18-
/// <param name="client">The CurseForge API client.</param>
19-
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
20-
Task<bool> CanRefreshFromAsync(ICurseForgeExportApiClient client, int staleMinutes);
21-
2212
/// <summary>Get the cached data for a mod, if it exists in the export.</summary>
2313
/// <param name="id">The CurseForge mod ID.</param>
2414
/// <param name="mod">The fetched metadata.</param>
@@ -27,9 +17,5 @@ internal interface ICurseForgeExportCacheRepository : ICacheRepository
2717
/// <summary>Set the cached data to use.</summary>
2818
/// <param name="export">The export received from the CurseForge Mods API, or <c>null</c> to remove it.</param>
2919
void SetData(CurseForgeFullExport? export);
30-
31-
/// <summary>Get whether the cached data is stale.</summary>
32-
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
33-
bool IsStale(int staleMinutes);
3420
}
3521
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
3+
namespace StardewModdingAPI.Web.Framework.Caching
4+
{
5+
/// <summary>Encapsulates logic for accessing data in a cached mod export from a remote API.</summary>
6+
internal interface IExportCacheRepository : ICacheRepository
7+
{
8+
/*********
9+
** Methods
10+
*********/
11+
/// <summary>Get whether the export data is currently available.</summary>
12+
bool IsLoaded();
13+
14+
/// <summary>Get the date when the cached data was last modified.</summary>
15+
DateTimeOffset GetLastModified();
16+
17+
/// <summary>Get whether the cached data is stale.</summary>
18+
/// <param name="staleMinutes">The age in minutes before data is considered stale.</param>
19+
bool IsStale(int staleMinutes);
20+
21+
/// <summary>Clear all data in the cache.</summary>
22+
void Clear();
23+
}
24+
}

0 commit comments

Comments
 (0)