split up BackgroundService

This splits background jobs into:
- a `BackgroundService` which manages the server and retry policy.
- one class per job which defines its logic, schedule options, and state.
This commit is contained in:
Jesse Plamondon-Willard
2026-09-28 19:49:35 -04:00
parent d6f868ecef
commit 5f519d6fc2
12 changed files with 654 additions and 414 deletions
@@ -0,0 +1,50 @@
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Console;
using Hangfire.Server;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which updates the cached compatibility list data.</summary>
internal class CompatibilityListJob : IBackgroundJob
{
/*********
** Fields
*********/
/// <summary>The cache in which to store compatibility list data.</summary>
private readonly ICompatibilityCacheRepository CompatibilityCache;
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update compatibility list", Cron.MinuteInterval(10));
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="compatibilityCache"><inheritdoc cref="CompatibilityCache" path="/summary"/></param>
public CompatibilityListJob(ICompatibilityCacheRepository compatibilityCache)
{
this.CompatibilityCache = compatibilityCache;
}
/// <inheritdoc />
public async Task RunAsync(PerformContext? context)
{
context.WriteLine("Fetching data from compatibility repo...");
ModCompatibilityEntry[] compatList = await new ModToolkit().GetCompatibilityListAsync();
context.WriteLine("Saving data...");
this.CompatibilityCache.SaveData(compatList);
context.WriteLine("Done!");
}
}
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Hangfire;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Framework.Clients;
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which updates the cached CurseForge mod export.</summary>
internal class CurseForgeExportJob : ModSiteExportJob<ICurseForgeExportCacheRepository, ICurseForgeExportApiClient>, IBackgroundJob
{
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update CurseForge export", Cron.MinuteInterval(10));
/// <inheritdoc />
public override bool IsEnabled => this.Client is not DisabledCurseForgeExportApiClient;
/*********
** Public methods
*********/
/// <inheritdoc />
public CurseForgeExportJob(ICurseForgeExportCacheRepository cache, ICurseForgeExportApiClient client, IOptions<ModUpdateCheckConfig> updateCheckConfig)
: base(cache, client, updateCheckConfig) { }
/*********
** Protected methods
*********/
/// <inheritdoc />
protected override Task<ApiCacheHeaders> FetchCacheHeadersAsync()
{
return this.Client.FetchCacheHeadersAsync();
}
/// <inheritdoc />
protected override async Task FetchDataAsync()
{
this.Cache.SetData(await this.Client.FetchExportAsync());
}
}
@@ -0,0 +1,108 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Console;
using Hangfire.Server;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which fetches the malware blacklist and converts it into the static file fetched by SMAPI.</summary>
internal class MalwareBlacklistJob : IBackgroundJob
{
/*********
** Fields
*********/
/// <summary>The HTTP client for fetching data from GitHub.</summary>
private readonly IGitHubClient GitHubClient;
/// <summary>The config settings for syncing the malware blacklist.</summary>
private readonly IOptions<MalwareBlacklistConfig> Config;
/// <summary>The web root path for writing static data files.</summary>
private readonly string WebRootPath;
/// <summary>The HTTP ETag header for the last fetched malware blacklist file, if any.</summary>
private string? LastETag;
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update malware blacklist", Cron.MinuteInterval(10));
/// <summary>Whether the job should be scheduled.</summary>
public bool IsEnabled => !string.IsNullOrWhiteSpace(this.Config.Value.GitHubRepo);
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="gitHubClient"><inheritdoc cref="GitHubClient" path="/summary"/></param>
/// <param name="config"><inheritdoc cref="Config" path="/summary"/></param>
/// <param name="hostEnvironment">The web host environment metadata.</param>
public MalwareBlacklistJob(IGitHubClient gitHubClient, IOptions<MalwareBlacklistConfig> config, IWebHostEnvironment hostEnvironment)
{
this.GitHubClient = gitHubClient;
this.Config = config;
this.WebRootPath = hostEnvironment.WebRootPath;
}
/// <inheritdoc />
public async Task RunAsync(PerformContext? context)
{
// get config
MalwareBlacklistConfig config = this.Config.Value;
string repo = config.GitHubRepo ?? throw new InvalidOperationException("Can't update the malware blacklist: no GitHub repository is configured.");
string outputPath = Path.Combine(this.WebRootPath, Startup.BlacklistFileName);
// fetch file
GitFile file;
{
context.WriteLine($"Fetching '{config.FilePath}' from {repo}{(config.GitRef != null ? $"@{config.GitRef}" : "")}...");
string? previousETag = File.Exists(outputPath)
? this.LastETag
: null;
file =
await this.GitHubClient.GetFileAsync(repo, config.FilePath, config.GitRef, previousETag)
?? throw new InvalidOperationException($"Can't update the malware blacklist: file '{config.FilePath}' not found in GitHub repository '{repo}'. If the repository is private, the configured GitHub credentials may not have read access to it.");
}
// skip if unchanged
if (!file.IsModified)
{
context.WriteLine($"Skipped: file hasn't changed since the last sync (ETag {file.ETag}).");
return;
}
// convert to public format
context.WriteLine($"Parsing blacklist (ETag {file.ETag})...");
MalwareListModel rawBlacklist = MalwareBlacklistConverter.FromInternalFormat(file.Content);
ModBlacklistModel blacklist = MalwareBlacklistConverter.ToPublicModel(rawBlacklist);
string publicJson = MalwareBlacklistConverter.ToPublicJson(blacklist);
context.WriteLine($"Updated malware blacklist ({blacklist.Blacklist.Length} mod entries and {blacklist.LooseFileBlacklist.Length} loose file entries).");
// save file
{
string tempPath = $"{outputPath}.tmp";
await File.WriteAllTextAsync(tempPath, publicJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
File.Move(tempPath, outputPath, overwrite: true);
context.WriteLine($"Saved to {Startup.BlacklistFileName}.");
}
this.LastETag = file.ETag;
context.WriteLine("Done!");
}
}
@@ -0,0 +1,82 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Console;
using Hangfire.Server;
using Microsoft.AspNetCore.Hosting;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.ModDataset;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which updates the local copy of the Stardew mod dataset for use by the frontend scripts.</summary>
internal class ModDatasetJob : IBackgroundJob
{
/*********
** Fields
*********/
/// <summary>The mod dataset repository.</summary>
private readonly IModDatasetRepository ModDatasetRepo;
/// <summary>The web root path for writing static data files.</summary>
private readonly string WebRootPath;
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update mod dataset", Cron.Hourly());
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modDatasetRepo"><inheritdoc cref="ModDatasetRepo" path="/summary"/></param>
/// <param name="hostEnvironment">The web host environment metadata.</param>
public ModDatasetJob(IModDatasetRepository modDatasetRepo, IWebHostEnvironment hostEnvironment)
{
this.ModDatasetRepo = modDatasetRepo;
this.WebRootPath = hostEnvironment.WebRootPath;
}
/// <inheritdoc />
public async Task RunAsync(PerformContext? context)
{
// update repo
context.WriteLine("Updating mod dataset repo...");
DatasetDownload result = await this.ModDatasetRepo.UpdateAsync(context.WriteLine);
// copy files
context.WriteLine("Copying data files for script use...");
this.CopyToData("stats", "mods by type.jsonl");
this.CopyToData("stats", "Content Patcher packs by format version.jsonl", "content-packs-by-format.jsonl");
this.CopyToData("reference-data", "SMAPI costs.jsonl");
this.CopyToData("reference-data", "SMAPI DNS queries.json");
Program.ModDatasetCacheBustValue = result.ETag ?? $"epoch-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
context.WriteLine("Done!");
}
/*********
** Private methods
*********/
/// <summary>Copy a fetched file into the web content data directory.</summary>
/// <param name="fromRelativeDir">The relative path to the directory containing the file to copy within the fetched repo.</param>
/// <param name="fromFileName">The file name within the fetched repo to copy.</param>
/// <param name="toFileName">The file name to save it to within the web content data directory, or <c>null</c> to use a lowercase spaces-to-hyphenated version of <paramref name="fromFileName"/>.</param>
private void CopyToData(string fromRelativeDir, string fromFileName, string? toFileName = null)
{
string fromRelativePath = Path.Combine(fromRelativeDir, fromFileName);
toFileName ??= fromFileName.Replace(' ', '-').ToLowerInvariant();
string toAbsolutePath = Path.Combine(this.WebRootPath, "Content", "data", toFileName);
Directory.CreateDirectory(Path.GetDirectoryName(toAbsolutePath)!);
File.Copy(this.ModDatasetRepo.GetFilePath(fromRelativePath), toAbsolutePath, overwrite: true);
}
}
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Hangfire;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Framework.Clients;
using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.ModDropExport;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which updates the cached ModDrop mod export.</summary>
internal class ModDropExportJob : ModSiteExportJob<IModDropExportCacheRepository, IModDropExportApiClient>, IBackgroundJob
{
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update ModDrop export", Cron.MinuteInterval(10));
/// <inheritdoc />
public override bool IsEnabled => this.Client is not DisabledModDropExportApiClient;
/*********
** Public methods
*********/
/// <inheritdoc />
public ModDropExportJob(IModDropExportCacheRepository cache, IModDropExportApiClient client, IOptions<ModUpdateCheckConfig> updateCheckConfig)
: base(cache, client, updateCheckConfig) { }
/*********
** Protected methods
*********/
/// <inheritdoc />
protected override Task<ApiCacheHeaders> FetchCacheHeadersAsync()
{
return this.Client.FetchCacheHeadersAsync();
}
/// <inheritdoc />
protected override async Task FetchDataAsync()
{
this.Cache.SetData(await this.Client.FetchExportAsync());
}
}
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Hangfire;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Framework.Clients;
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which updates the cached Nexus mod export.</summary>
internal class NexusExportJob : ModSiteExportJob<INexusExportCacheRepository, INexusExportApiClient>, IBackgroundJob
{
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("update Nexus export", Cron.MinuteInterval(10));
/// <inheritdoc />
public override bool IsEnabled => this.Client is not DisabledNexusExportApiClient;
/*********
** Public methods
*********/
/// <inheritdoc />
public NexusExportJob(INexusExportCacheRepository cache, INexusExportApiClient client, IOptions<ModUpdateCheckConfig> updateCheckConfig)
: base(cache, client, updateCheckConfig) { }
/*********
** Protected methods
*********/
/// <inheritdoc />
protected override Task<ApiCacheHeaders> FetchCacheHeadersAsync()
{
return this.Client.FetchCacheHeadersAsync();
}
/// <inheritdoc />
protected override async Task FetchDataAsync()
{
this.Cache.SetData(await this.Client.FetchExportAsync());
}
}
@@ -0,0 +1,46 @@
using System;
using System.Threading.Tasks;
using Hangfire.Server;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
using StardewModdingAPI.Web.Framework.Caching.Mods;
namespace StardewModdingAPI.Web.BackgroundJobs;
/// <summary>A background job which removes mods from the cache if they haven't been requested recently.</summary>
internal class RemoveStaleModsJob : IBackgroundJob
{
/*********
** Fields
*********/
/// <summary>The time for which to keep cached mods after they're last requested.</summary>
private readonly TimeSpan CacheTime = TimeSpan.FromHours(48);
/// <summary>The cache in which to store mod data.</summary>
private readonly IModCacheRepository ModCache;
/*********
** Accessors
*********/
/// <inheritdoc />
public static JobOptions Options { get; } = new("remove stale mods", "2/10 * * * *"); // every 10 minutes, but offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc)
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="modCache"><inheritdoc cref="ModCache" path="/summary"/></param>
public RemoveStaleModsJob(IModCacheRepository modCache)
{
this.ModCache = modCache;
}
/// <inheritdoc />
public Task RunAsync(PerformContext? context)
{
this.ModCache.RemoveStaleMods(this.CacheTime);
return Task.CompletedTask;
}
}
+55 -414
View File
@@ -1,469 +1,110 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Console;
using Hangfire.Server;
using Humanizer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients;
using StardewModdingAPI.Toolkit.Framework.Clients.CompatibilityRepo;
using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport;
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
using StardewModdingAPI.Toolkit.Framework.ModBlacklistData;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
using StardewModdingAPI.Web.Framework.Caching.ModDataset;
using StardewModdingAPI.Web.Framework.Caching.ModDropExport;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
using StardewModdingAPI.Web.Framework.Clients.CurseForge;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.ModDrop;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.BackgroundJobs;
using StardewModdingAPI.Web.Framework.BackgroundJobs;
namespace StardewModdingAPI.Web;
/// <summary>A hosted service which runs background data updates.</summary>
/// <remarks>Task methods need to be static, since otherwise Hangfire will try to serialize the entire instance.</remarks>
/// <summary>A hosted service which schedules and runs the background jobs.</summary>
internal class BackgroundService : IHostedService, IDisposable
{
/*********
** Fields
*********/
/// <summary>The background task server.</summary>
private static BackgroundJobServer? JobServer;
/// <summary>The background job server, if started.</summary>
private BackgroundJobServer? JobServer;
/// <summary>The cache in which to store compatibility list data.</summary>
private static ICompatibilityCacheRepository? CompatibilityCache;
/// <summary>Whether the <see cref="CurseForgeExportJob"/> job is enabled.</summary>
private readonly bool EnableCurseForgeExport;
/// <summary>The cache in which to store mod data.</summary>
private static IModCacheRepository? ModCache;
/// <summary>Whether the <see cref="ModDropExportJob"/> job is enabled.</summary>
private readonly bool EnableModDropExport;
/// <summary>The HTTP client for fetching the mod export from the CurseForge export API.</summary>
private static ICurseForgeExportApiClient? CurseForgeExportApiClient;
/// <summary>Whether the <see cref="NexusExportJob"/> job is enabled.</summary>
private readonly bool EnableNexusExport;
/// <summary>The cache in which to store the mod data from the CurseForge export API.</summary>
private static ICurseForgeExportCacheRepository? CurseForgeExportCache;
/// <summary>The HTTP client for fetching data from GitHub.</summary>
private static IGitHubClient? GitHubClient;
/// <summary>The HTTP client for fetching the mod export from the ModDrop export API.</summary>
private static IModDropExportApiClient? ModDropExportApiClient;
/// <summary>The cache in which to store the mod data from the ModDrop export API.</summary>
private static IModDropExportCacheRepository? ModDropExportCache;
/// <summary>The cache in which to store mod data from the Nexus export API.</summary>
private static INexusExportCacheRepository? NexusExportCache;
/// <summary>The HTTP client for fetching the mod export from the Nexus Mods export API.</summary>
private static INexusExportApiClient? NexusExportApiClient;
/// <summary>The config settings for mod update checks.</summary>
private static IOptions<ModUpdateCheckConfig>? UpdateCheckConfig;
/// <summary>The mod dataset repository.</summary>
private static IModDatasetRepository? ModDatasetRepo;
/// <summary>The config settings for syncing the malware blacklist.</summary>
private static IOptions<MalwareBlacklistConfig>? MalwareBlacklistConfig;
/// <summary>The HTTP ETag header for the last fetched malware blacklist file, if any.</summary>
private static string? LastMalwareBlacklistETag;
/// <summary>The web root path for writing static data files.</summary>
private static string? WebRootPath;
/// <summary>Whether the service has been started.</summary>
[MemberNotNullWhen(true,
nameof(BackgroundService.JobServer),
nameof(BackgroundService.ModCache),
nameof(BackgroundService.CompatibilityCache),
nameof(BackgroundService.CurseForgeExportApiClient),
nameof(BackgroundService.CurseForgeExportCache),
nameof(BackgroundService.GitHubClient),
nameof(BackgroundService.ModDropExportApiClient),
nameof(BackgroundService.ModDropExportCache),
nameof(BackgroundService.NexusExportApiClient),
nameof(BackgroundService.NexusExportCache),
nameof(BackgroundService.ModDatasetRepo),
nameof(BackgroundService.MalwareBlacklistConfig),
nameof(BackgroundService.UpdateCheckConfig),
nameof(BackgroundService.WebRootPath)
)]
private static bool IsStarted { get; set; }
/// <summary>The number of minutes a site export should be considered valid based on its last-updated date before it's ignored.</summary>
private static int ExportStaleAge => (BackgroundService.UpdateCheckConfig?.Value.SuccessCacheMinutes ?? 0) + 10;
/// <summary>Whether the <see cref="MalwareBlacklistJob"/> job is enabled.</summary>
private readonly bool EnableMalwareBlacklistSync;
/*********
** Public methods
*********/
/****
** Hosted service
****/
/// <summary>Construct an instance.</summary>
/// <param name="compatibilityCache"><inheritdoc cref="CompatibilityCache" path="/summary"/></param>
/// <param name="modCache"><inheritdoc cref="ModCache" path="/summary"/></param>
/// <param name="curseForgeExportCache"><inheritdoc cref="CurseForgeExportCache" path="/summary"/></param>
/// <param name="curseForgeExportApiClient"><inheritdoc cref="CurseForgeExportApiClient" path="/summary"/></param>
/// <param name="gitHubClient"><inheritdoc cref="GitHubClient" path="/summary"/></param>
/// <param name="modDropExportCache"><inheritdoc cref="ModDropExportCache" path="/summary"/></param>
/// <param name="modDropExportApiClient"><inheritdoc cref="ModDropExportApiClient" path="/summary"/></param>
/// <param name="nexusExportCache"><inheritdoc cref="NexusExportCache" path="/summary"/></param>
/// <param name="nexusExportApiClient"><inheritdoc cref="NexusExportApiClient" path="/summary"/></param>
/// <param name="modDatasetRepo"><inheritdoc cref="ModDatasetRepo" path="/summary"/></param>
/// <param name="curseForgeExportJob"><inheritdoc cref="CurseForgeExportJob" path="/summary"/></param>
/// <param name="modDropExportJob"><inheritdoc cref="ModDropExportJob" path="/summary"/></param>
/// <param name="nexusExportJob"><inheritdoc cref="NexusExportJob" path="/summary"/></param>
/// <param name="malwareBlacklistJob"><inheritdoc cref="MalwareBlacklistJob" path="/summary"/></param>
/// <param name="hangfireStorage">The Hangfire storage implementation.</param>
/// <param name="malwareBlacklistConfig"><inheritdoc cref="MalwareBlacklistConfig" path="/summary"/></param>
/// <param name="updateCheckConfig"><inheritdoc cref="UpdateCheckConfig" path="/summary"/></param>
/// <param name="hostEnvironment">The web host environment metadata.</param>
[SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")]
public BackgroundService(
ICompatibilityCacheRepository compatibilityCache,
IModCacheRepository modCache,
ICurseForgeExportCacheRepository curseForgeExportCache,
ICurseForgeExportApiClient curseForgeExportApiClient,
IGitHubClient gitHubClient,
IModDropExportCacheRepository modDropExportCache,
IModDropExportApiClient modDropExportApiClient,
INexusExportCacheRepository nexusExportCache,
INexusExportApiClient nexusExportApiClient,
IModDatasetRepository modDatasetRepo,
JobStorage hangfireStorage,
IOptions<MalwareBlacklistConfig> malwareBlacklistConfig,
IOptions<ModUpdateCheckConfig> updateCheckConfig,
IWebHostEnvironment hostEnvironment
)
public BackgroundService(CurseForgeExportJob curseForgeExportJob, ModDropExportJob modDropExportJob, NexusExportJob nexusExportJob, MalwareBlacklistJob malwareBlacklistJob, JobStorage hangfireStorage)
{
BackgroundService.CompatibilityCache = compatibilityCache;
BackgroundService.ModCache = modCache;
BackgroundService.CurseForgeExportApiClient = curseForgeExportApiClient;
BackgroundService.CurseForgeExportCache = curseForgeExportCache;
BackgroundService.GitHubClient = gitHubClient;
BackgroundService.ModDropExportApiClient = modDropExportApiClient;
BackgroundService.ModDropExportCache = modDropExportCache;
BackgroundService.NexusExportCache = nexusExportCache;
BackgroundService.NexusExportApiClient = nexusExportApiClient;
BackgroundService.ModDatasetRepo = modDatasetRepo;
BackgroundService.MalwareBlacklistConfig = malwareBlacklistConfig;
BackgroundService.UpdateCheckConfig = updateCheckConfig;
BackgroundService.WebRootPath = hostEnvironment.WebRootPath;
this.EnableCurseForgeExport = curseForgeExportJob.IsEnabled;
this.EnableModDropExport = modDropExportJob.IsEnabled;
this.EnableNexusExport = nexusExportJob.IsEnabled;
this.EnableMalwareBlacklistSync = malwareBlacklistJob.IsEnabled;
_ = hangfireStorage; // parameter is only received to initialize it before the background service
}
/// <summary>Start the service.</summary>
/// <param name="cancellationToken">Tracks whether the start process has been aborted.</param>
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
this.TryInit();
if (this.JobServer != null)
throw new InvalidOperationException("The scheduler service is already started.");
bool enableCurseForgeExport = BackgroundService.CurseForgeExportApiClient is not DisabledCurseForgeExportApiClient;
bool enableModDropExport = BackgroundService.ModDropExportApiClient is not DisabledModDropExportApiClient;
bool enableNexusExport = BackgroundService.NexusExportApiClient is not DisabledNexusExportApiClient;
bool enableMalwareBlacklist = !string.IsNullOrWhiteSpace(BackgroundService.MalwareBlacklistConfig?.Value.GitHubRepo);
// set retry policy
GlobalJobFilters.Filters.Remove<AutomaticRetryAttribute>();
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 3, DelaysInSeconds = [30, 60, 120] });
// set startup tasks
BackgroundJob.Enqueue(() => BackgroundService.UpdateCompatibilityListAsync(null));
if (enableCurseForgeExport)
BackgroundJob.Enqueue(() => BackgroundService.UpdateCurseForgeExportAsync(null));
if (enableModDropExport)
BackgroundJob.Enqueue(() => BackgroundService.UpdateModDropExportAsync(null));
if (enableNexusExport)
BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null));
BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync());
BackgroundJob.Enqueue(() => BackgroundService.UpdateModDatasetAsync(null));
if (enableMalwareBlacklist)
BackgroundJob.Enqueue(() => BackgroundService.UpdateMalwareBlacklistAsync(null));
// start Hangfire server
this.JobServer = new BackgroundJobServer();
// set recurring tasks
RecurringJob.AddOrUpdate("update compatibility list", () => BackgroundService.UpdateCompatibilityListAsync(null), "*/10 * * * *"); // every 10 minutes
if (enableCurseForgeExport)
RecurringJob.AddOrUpdate("update CurseForge export", () => BackgroundService.UpdateCurseForgeExportAsync(null), "*/10 * * * *");
if (enableModDropExport)
RecurringJob.AddOrUpdate("update ModDrop export", () => BackgroundService.UpdateModDropExportAsync(null), "*/10 * * * *");
if (enableNexusExport)
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)
RecurringJob.AddOrUpdate("update mod dataset", () => BackgroundService.UpdateModDatasetAsync(null), "0 * * * *"); // hourly
if (enableMalwareBlacklist)
RecurringJob.AddOrUpdate("update malware blacklist", () => BackgroundService.UpdateMalwareBlacklistAsync(null), "*/10 * * * *");
BackgroundService.IsStarted = true;
// register jobs
this.RegisterJob<CompatibilityListJob>();
this.RegisterJob<ModDatasetJob>();
this.RegisterJob<RemoveStaleModsJob>();
if (this.EnableMalwareBlacklistSync)
this.RegisterJob<MalwareBlacklistJob>();
if (this.EnableCurseForgeExport)
this.RegisterJob<CurseForgeExportJob>();
if (this.EnableModDropExport)
this.RegisterJob<ModDropExportJob>();
if (this.EnableNexusExport)
this.RegisterJob<NexusExportJob>();
return Task.CompletedTask;
}
/// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
/// <param name="cancellationToken">Tracks whether the shutdown process should no longer be graceful.</param>
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
BackgroundService.IsStarted = false;
if (BackgroundService.JobServer != null)
await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken);
if (this.JobServer != null)
await this.JobServer.WaitForShutdownAsync(cancellationToken);
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
/// <inheritdoc />
public void Dispose()
{
BackgroundService.IsStarted = false;
BackgroundService.JobServer?.Dispose();
}
/****
** Tasks
****/
/// <summary>Update the cached compatibility list data.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateCompatibilityListAsync(PerformContext? context)
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
context.WriteLine("Fetching data from compatibility repo...");
ModCompatibilityEntry[] compatList = await new ModToolkit().GetCompatibilityListAsync();
context.WriteLine("Saving data...");
BackgroundService.CompatibilityCache.SaveData(compatList);
context.WriteLine("Done!");
}
/// <summary>Update the cached CurseForge mod export.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateCurseForgeExportAsync(PerformContext? context)
{
await UpdateExportAsync(
context,
BackgroundService.CurseForgeExportCache!,
BackgroundService.CurseForgeExportApiClient!,
fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(),
fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync())
);
}
/// <summary>Update the cached ModDrop mod export.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateModDropExportAsync(PerformContext? context)
{
await UpdateExportAsync(
context,
BackgroundService.ModDropExportCache!,
BackgroundService.ModDropExportApiClient!,
fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(),
fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync())
);
}
/// <summary>Update the cached Nexus mod export.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateNexusExportAsync(PerformContext? context)
{
await UpdateExportAsync(
context,
BackgroundService.NexusExportCache!,
BackgroundService.NexusExportApiClient!,
fetchCacheHeadersAsync: client => client.FetchCacheHeadersAsync(),
fetchDataAsync: async (cache, client) => cache.SetData(await client.FetchExportAsync())
);
}
/// <summary>Clone or pull the Stardew mod dataset repo, then copy the latest data files into the web root for use by frontend scripts.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateModDatasetAsync(PerformContext? context)
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
// update repo
context.WriteLine("Updating mod dataset repo...");
DatasetDownload result = await BackgroundService.ModDatasetRepo.UpdateAsync(context.WriteLine);
// copy files
context.WriteLine("Copying data files for script use...");
string copyToPath = Path.Combine(BackgroundService.WebRootPath, "Content", "data", "mods-by-type.jsonl");
Directory.CreateDirectory(Path.GetDirectoryName(copyToPath)!);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/mods by type.jsonl"), copyToPath, overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/Content Patcher packs by format version.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "content-packs-by-format.jsonl"), overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("reference-data/SMAPI costs.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "smapi-costs.jsonl"), overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("reference-data/SMAPI DNS queries.json"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "smapi-dns-queries.json"), overwrite: true);
Program.ModDatasetCacheBustValue = result.ETag ?? $"epoch-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
context.WriteLine("Done!");
}
/// <summary>Fetch the malware blacklist and convert it into the static file fetched by SMAPI.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateMalwareBlacklistAsync(PerformContext? context)
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
// get config
MalwareBlacklistConfig config = BackgroundService.MalwareBlacklistConfig.Value;
string repo = config.GitHubRepo ?? throw new InvalidOperationException("Can't update the malware blacklist: no GitHub repository is configured.");
string outputPath = Path.Combine(BackgroundService.WebRootPath, Startup.BlacklistFileName);
// fetch file
GitFile file;
{
context.WriteLine($"Fetching '{config.FilePath}' from {repo}{(config.GitRef != null ? $"@{config.GitRef}" : "")}...");
string? previousETag = File.Exists(outputPath)
? BackgroundService.LastMalwareBlacklistETag
: null;
file =
await BackgroundService.GitHubClient.GetFileAsync(repo, config.FilePath, config.GitRef, previousETag)
?? throw new InvalidOperationException($"Can't update the malware blacklist: file '{config.FilePath}' not found in GitHub repository '{repo}'. If the repository is private, the configured GitHub credentials may not have read access to it.");
}
// skip if unchanged
if (!file.IsModified)
{
context.WriteLine($"Skipped: file hasn't changed since the last sync (ETag {file.ETag}).");
return;
}
// convert to public format
context.WriteLine($"Parsing blacklist (ETag {file.ETag})...");
MalwareListModel rawBlacklist = MalwareBlacklistConverter.FromInternalFormat(file.Content);
ModBlacklistModel blacklist = MalwareBlacklistConverter.ToPublicModel(rawBlacklist);
string publicJson = MalwareBlacklistConverter.ToPublicJson(blacklist);
context.WriteLine($"Updated malware blacklist ({blacklist.Blacklist.Length} mod entries and {blacklist.LooseFileBlacklist.Length} loose file entries).");
// save file
{
string tempPath = $"{outputPath}.tmp";
await File.WriteAllTextAsync(tempPath, publicJson, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
File.Move(tempPath, outputPath, overwrite: true);
context.WriteLine($"Saved to {Startup.BlacklistFileName}.");
}
BackgroundService.LastMalwareBlacklistETag = file.ETag;
context.WriteLine("Done!");
}
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
public static Task RemoveStaleModsAsync()
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
// remove mods in mod cache
BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48));
return Task.CompletedTask;
this.JobServer?.Dispose();
}
/*********
** Private method
** Private methods
*********/
/// <summary>Initialize the background service if it's not already initialized.</summary>
/// <exception cref="InvalidOperationException">The background service is already initialized.</exception>
private void TryInit()
/// <summary>Register a background job so it's enqueued immediately, and also runs on a recurring schedule.</summary>
/// <typeparam name="TJob">The job type.</typeparam>
private void RegisterJob<TJob>()
where TJob : IBackgroundJob
{
if (BackgroundService.JobServer != null)
throw new InvalidOperationException("The scheduler service is already started.");
JobOptions options = TJob.Options;
BackgroundService.JobServer = new BackgroundJobServer();
}
/// <summary>Update the cached mods export for a site.</summary>
/// <typeparam name="TCacheRepository">The export cache repository type.</typeparam>
/// <typeparam name="TExportApiClient">The export API client.</typeparam>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
/// <param name="cache">The export cache to update.</param>
/// <param name="client">The export API with which to fetch data from the remote API.</param>
/// <param name="fetchCacheHeadersAsync">Fetch the HTTP cache headers set by the remote API.</param>
/// <param name="fetchDataAsync">Fetch the latest export file from the Nexus Mods export API.</param>
/// <exception cref="InvalidOperationException">The <see cref="StartAsync"/> method wasn't called before running this task.</exception>
private static async Task UpdateExportAsync<TCacheRepository, TExportApiClient>(PerformContext? context, TCacheRepository cache, TExportApiClient client, Func<TExportApiClient, Task<ApiCacheHeaders>> fetchCacheHeadersAsync, Func<TCacheRepository, TExportApiClient, Task> fetchDataAsync)
where TCacheRepository : IExportCacheRepository
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
// log initial state
context.WriteLine(cache.IsLoaded
? $"The previous export is cached with data from {BackgroundService.FormatDateModified(cache.CacheHeaders.LastModified)}."
: "No previous export is cached."
);
// fetch cache headers
context.WriteLine("Fetching cache headers...");
ApiCacheHeaders serverCacheHeaders = await fetchCacheHeadersAsync(client);
DateTimeOffset serverModified = serverCacheHeaders.LastModified;
string? serverEntityTag = serverCacheHeaders.EntityTag;
// update data
{
// skip if no update needed
if (cache.IsStale(serverModified, BackgroundService.ExportStaleAge))
context.WriteLine($"Skipped data fetch: server was last modified {BackgroundService.FormatDateModified(serverModified)}, which exceeds the {BackgroundService.ExportStaleAge}-minute-stale limit.");
else if (cache.IsLoaded && cache.CacheHeaders.LastModified >= serverModified)
context.WriteLine($"Skipped data fetch: server was last modified {BackgroundService.FormatDateModified(serverModified)}, which {(serverModified == cache.CacheHeaders.LastModified ? "matches" : "is older than")} our cached data.");
// update cache headers if data unchanged
else if (cache.IsLoaded && cache.CacheHeaders.EntityTag != null && cache.CacheHeaders.EntityTag == serverEntityTag)
{
context.WriteLine($"Skipped data fetch: server provided entity tag '{serverEntityTag}', which already matches the data we have.");
cache.SetCacheHeaders(serverCacheHeaders);
}
// else update data
else
{
context.WriteLine("Fetching data...");
await fetchDataAsync(cache, client);
}
}
// clear if stale
if (cache.IsStale(BackgroundService.ExportStaleAge))
{
context.WriteLine("The cached data is stale, clearing cache...");
cache.Clear();
}
// log final result
context.WriteLine(cache.IsLoaded
? $"Done! The export is currently cached with data from {BackgroundService.FormatDateModified(cache.CacheHeaders.LastModified)}."
: "Done! The export cache is currently disabled."
);
}
/// <summary>Format a 'date modified' value for the task logs.</summary>
/// <param name="date">The date to log.</param>
private static string FormatDateModified(DateTimeOffset? date)
{
if (!date.HasValue)
return "<null>";
string ageLabel = (DateTimeOffset.UtcNow - date.Value).Humanize(precision: 2, minUnit: TimeUnit.Minute, maxUnit: TimeUnit.Hour);
return $"{date.Value:O} (age: {ageLabel})";
BackgroundJob.Enqueue<TJob>(job => job.RunAsync(null));
RecurringJob.AddOrUpdate<TJob>(options.Id, job => job.RunAsync(null), options.CronSchedule);
}
}
@@ -0,0 +1,22 @@
using System.Threading.Tasks;
using Hangfire.Server;
namespace StardewModdingAPI.Web.Framework.BackgroundJobs;
/// <summary>A background job scheduled via <see cref="BackgroundService"/>.</summary>
internal interface IBackgroundJob
{
/*********
** Accessors
*********/
/// <summary>The recurring job options.</summary>
static abstract JobOptions Options { get; }
/*********
** Methods
*********/
/// <summary>Run the background job.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
Task RunAsync(PerformContext? context);
}
@@ -0,0 +1,6 @@
namespace StardewModdingAPI.Web.Framework.BackgroundJobs;
/// <summary>The Hangfire options for a recurring scheduled job.</summary>
/// <param name="Id">The unique job ID.</param>
/// <param name="CronSchedule">The CRON schedule expression.</param>
internal record JobOptions(string Id, string CronSchedule);
@@ -0,0 +1,131 @@
using System;
using System.Threading.Tasks;
using Hangfire.Console;
using Hangfire.Server;
using Humanizer;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Framework.Clients;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.Framework.BackgroundJobs;
/// <summary>The base implementation for a background job which updates the cached mod export for a mod site.</summary>
/// <typeparam name="TCacheRepository">The export cache repository type.</typeparam>
/// <typeparam name="TExportApiClient">The export API client type.</typeparam>
internal abstract class ModSiteExportJob<TCacheRepository, TExportApiClient>
where TCacheRepository : IExportCacheRepository
{
/*********
** Fields
*********/
/// <summary>The config settings for mod update checks.</summary>
private readonly IOptions<ModUpdateCheckConfig> UpdateCheckConfig;
/// <summary>The cache in which to store the mod export.</summary>
protected readonly TCacheRepository Cache;
/// <summary>The HTTP client for fetching the mod export from the site's export API.</summary>
protected readonly TExportApiClient Client;
/*********
** Accessors
*********/
/// <summary>Whether the job should be scheduled.</summary>
public abstract bool IsEnabled { get; }
/// <summary>The number of minutes a site export should be considered valid based on its last-updated date before it's ignored.</summary>
private int ExportStaleAge => this.UpdateCheckConfig.Value.SuccessCacheMinutes + 10;
/*********
** Public methods
*********/
/// <inheritdoc cref="IBackgroundJob.RunAsync" />
public async Task RunAsync(PerformContext? context)
{
TCacheRepository cache = this.Cache;
// log initial state
context.WriteLine(cache.IsLoaded
? $"The previous export is cached with data from {this.FormatDateModified(cache.CacheHeaders.LastModified)}."
: "No previous export is cached."
);
// fetch cache headers
context.WriteLine("Fetching cache headers...");
ApiCacheHeaders serverCacheHeaders = await this.FetchCacheHeadersAsync();
DateTimeOffset serverModified = serverCacheHeaders.LastModified;
string? serverEntityTag = serverCacheHeaders.EntityTag;
// update data
{
// skip if no update needed
if (cache.IsStale(serverModified, this.ExportStaleAge))
context.WriteLine($"Skipped data fetch: server was last modified {this.FormatDateModified(serverModified)}, which exceeds the {this.ExportStaleAge}-minute-stale limit.");
else if (cache.IsLoaded && cache.CacheHeaders.LastModified >= serverModified)
context.WriteLine($"Skipped data fetch: server was last modified {this.FormatDateModified(serverModified)}, which {(serverModified == cache.CacheHeaders.LastModified ? "matches" : "is older than")} our cached data.");
// update cache headers if data unchanged
else if (cache.IsLoaded && cache.CacheHeaders.EntityTag != null && cache.CacheHeaders.EntityTag == serverEntityTag)
{
context.WriteLine($"Skipped data fetch: server provided entity tag '{serverEntityTag}', which already matches the data we have.");
cache.SetCacheHeaders(serverCacheHeaders);
}
// else update data
else
{
context.WriteLine("Fetching data...");
await this.FetchDataAsync();
}
}
// clear if stale
if (cache.IsStale(this.ExportStaleAge))
{
context.WriteLine("The cached data is stale, clearing cache...");
cache.Clear();
}
// log final result
context.WriteLine(cache.IsLoaded
? $"Done! The export is currently cached with data from {this.FormatDateModified(cache.CacheHeaders.LastModified)}."
: "Done! The export cache is currently disabled."
);
}
/*********
** Protected methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="cache"><inheritdoc cref="Cache" path="/summary"/></param>
/// <param name="client"><inheritdoc cref="Client" path="/summary"/></param>
/// <param name="updateCheckConfig"><inheritdoc cref="UpdateCheckConfig" path="/summary"/></param>
protected ModSiteExportJob(TCacheRepository cache, TExportApiClient client, IOptions<ModUpdateCheckConfig> updateCheckConfig)
{
this.Cache = cache;
this.Client = client;
this.UpdateCheckConfig = updateCheckConfig;
}
/// <summary>Fetch the HTTP cache headers set by the export API.</summary>
protected abstract Task<ApiCacheHeaders> FetchCacheHeadersAsync();
/// <summary>Fetch the latest export from the export API, and save it to the <see cref="Cache"/>.</summary>
protected abstract Task FetchDataAsync();
/// <summary>Format a 'date modified' value for the task logs.</summary>
/// <param name="date">The date to log.</param>
private string FormatDateModified(DateTimeOffset? date)
{
if (!date.HasValue)
return "<null>";
string ageLabel = (DateTimeOffset.UtcNow - date.Value).Humanize(precision: 2, minUnit: TimeUnit.Minute, maxUnit: TimeUnit.Hour);
return $"{date.Value:O} (age: {ageLabel})";
}
}
+10
View File
@@ -20,6 +20,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.CurseForgeExport;
using StardewModdingAPI.Toolkit.Framework.Clients.ModDropExport;
using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Web.BackgroundJobs;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
@@ -133,6 +134,15 @@ internal class Startup
});
// init background service
// These are singletons since some of them track state between runs.
services
.AddSingleton<CompatibilityListJob>()
.AddSingleton<CurseForgeExportJob>()
.AddSingleton<MalwareBlacklistJob>()
.AddSingleton<ModDatasetJob>()
.AddSingleton<ModDropExportJob>()
.AddSingleton<NexusExportJob>()
.AddSingleton<RemoveStaleModsJob>();
if (backgroundServicesConfig.Enabled)
services.AddHostedService<BackgroundService>();