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;
}
}