using System.Diagnostics; using Blog.Models; using Microsoft.Extensions.Options; namespace Blog.Services; /// Where the drop tables come from, and how long a copy of them is good for. public sealed class WarframeOptions { public const string Section = "Warframe"; /// /// DE's drop table page. It redirects to a CDN copy whose path is a content hash, so the /// redirect is followed rather than hardcoded. /// public string DropTablesUrl { get; set; } = "https://www.warframe.com/droptables"; /// /// How long a parsed copy is served for. DE republish the page a few times a month at most. /// public TimeSpan CacheFor { get; set; } = TimeSpan.FromHours(12); /// How long to wait on the download before giving up. public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); } /// /// 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 on a timer, so a /// field behind a gate is the whole mechanism. /// /// That only works because the service is a singleton. A typed /// AddHttpClient<WarframeDropService>() registration would make it transient, and /// every request would download and parse 4 MB into a cache nobody reads twice. /// /// A failed refresh keeps the copy it already had, and reports failure only with nothing at all. /// public sealed class WarframeDropService( IHttpClientFactory clients, IOptions options, ILogger logger) { /// The named client Program.cs configures for this service. public const string ClientName = "warframe"; private readonly WarframeOptions _options = options.Value; private readonly SemaphoreSlim _gate = new(1, 1); private DropTables? _tables; private DateTimeOffset _expiresAt = DateTimeOffset.MinValue; public async Task GetTablesAsync(CancellationToken cancellationToken = default) { if (_tables is { } fresh && DateTimeOffset.UtcNow < _expiresAt) { return DropTableStatus.Ready(fresh); } await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Someone else may have refreshed while this request queued for the gate. if (_tables is { } current && DateTimeOffset.UtcNow < _expiresAt) { return DropTableStatus.Ready(current); } var tables = await FetchAsync(cancellationToken).ConfigureAwait(false); _tables = tables; _expiresAt = DateTimeOffset.UtcNow + _options.CacheFor; return DropTableStatus.Ready(tables); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or TimeoutException) { logger.LogWarning(ex, "Could not read the Warframe drop tables from {Url}", _options.DropTablesUrl); // Back off well short of the normal life: a site that is down should not be hit on // every page view, and a blip should not cost half a day. _expiresAt = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5); return DropTableStatus.Failed( $"Could not reach {_options.DropTablesUrl} ({ex.Message})", _tables); } finally { _gate.Release(); } } private async Task FetchAsync(CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(_options.Timeout); // A client per fetch rather than one held for the life of the singleton. The factory pools // the handler underneath; a long-lived HttpClient would pin a stale CDN address. using var client = clients.CreateClient(ClientName); var stopwatch = Stopwatch.StartNew(); var html = await client.GetStringAsync(_options.DropTablesUrl, timeout.Token).ConfigureAwait(false); var downloaded = stopwatch.ElapsedMilliseconds; var tables = DropTableParser.Parse(html, DateTimeOffset.UtcNow); logger.LogInformation( "Read the Warframe drop tables ({Update}): {Bytes} bytes in {Downloaded} ms, {Items} items parsed in {Parsed} ms", tables.LastUpdate, html.Length, downloaded, tables.Names.Count, stopwatch.ElapsedMilliseconds - downloaded); return tables; } }