using System.Diagnostics; using Blog.Models; using Microsoft.Extensions.Options; namespace Blog.Services; /// Where the drop tables are read from. public sealed class WarframeOptions { public const string Section = "Warframe"; /// /// The vendored copy of DE's drop tables, relative to the deployed binary. warframe.com blocks /// requests from the server, so the page ships with the build instead; /// scripts/update-droptables.sh refreshes it before a publish. /// public string FilePath { get; set; } = "Resources/droptables.html"; } /// /// Keeps one parsed copy of the Warframe drop tables in memory and hands it to whoever asks. /// /// /// Not HybridCache, which the rest of this site uses: the parsed tables are tens of /// thousands of records, and HybridCache serializes what it stores. One value behind a gate is the /// whole mechanism, and it only works while the service is a singleton. /// /// The file cannot change without a deploy, which restarts the process, so the parse would be a /// one-off if not for `dotnet watch`. Keying the copy on the file's last write time costs one stat /// per request and picks up a refresh in development without a restart. /// public sealed class WarframeDropService( IOptions options, ILogger logger) { private readonly WarframeOptions _options = options.Value; private readonly SemaphoreSlim _gate = new(1, 1); private DropTables? _tables; private DateTimeOffset _parsedStamp; /// /// Resolved against the directory the binary sits in, not the content root and not the working /// directory. Both of those default to wherever the process was started from, so a unit file /// without a WorkingDirectory would send this looking in /. The drop tables ship next to the /// assembly, so that is what to anchor to. /// private string Path => System.IO.Path.Combine(AppContext.BaseDirectory, _options.FilePath); public async Task GetTablesAsync(CancellationToken cancellationToken = default) { var file = new FileInfo(Path); if (!file.Exists) { logger.LogError("The drop tables are missing from {Path}", Path); return DropTableStatus.Failed($"The drop tables are missing from {_options.FilePath}.", _tables); } var stamp = new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero); if (_tables is { } fresh && stamp == _parsedStamp) { return DropTableStatus.Ready(fresh); } await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Someone else may have parsed the same file while this request queued for the gate. if (_tables is { } current && stamp == _parsedStamp) { return DropTableStatus.Ready(current); } var tables = await ParseAsync(stamp, cancellationToken).ConfigureAwait(false); _tables = tables; _parsedStamp = stamp; return DropTableStatus.Ready(tables); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogError(ex, "Could not read the drop tables from {Path}", Path); return DropTableStatus.Failed($"Could not read the drop tables ({ex.Message})", _tables); } finally { _gate.Release(); } } private async Task ParseAsync(DateTimeOffset stamp, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); var html = await File.ReadAllTextAsync(Path, cancellationToken).ConfigureAwait(false); var tables = DropTableParser.Parse(html, stamp); logger.LogInformation( "Read the Warframe drop tables ({Update}): {Bytes} bytes, {Items} items in {Elapsed} ms", tables.LastUpdate, html.Length, tables.Names.Count, stopwatch.ElapsedMilliseconds); return tables; } }