Blog/Services/WarframeDropService.cs 4.1 K · 102 lines · raw · history

1 using System.Diagnostics;
2 using Blog.Models;
3 using Microsoft.Extensions.Options;
4
5 namespace Blog.Services;
6
7 /// <summary>Where the drop tables are read from.</summary>
8 public sealed class WarframeOptions
9 {
10 public const string Section = "Warframe";
11
12 /// <summary>
13 /// The vendored copy of DE's drop tables, relative to the deployed binary. warframe.com blocks
14 /// requests from the server, so the page ships with the build instead;
15 /// scripts/update-droptables.sh refreshes it before a publish.
16 /// </summary>
17 public string FilePath { get; set; } = "Resources/droptables.html";
18 }
19
20 /// <summary>
21 /// Keeps one parsed copy of the Warframe drop tables in memory and hands it to whoever asks.
22 /// </summary>
23 /// <remarks>
24 /// Not <c>HybridCache</c>, which the rest of this site uses: the parsed tables are tens of
25 /// thousands of records, and HybridCache serializes what it stores. One value behind a gate is the
26 /// whole mechanism, and it only works while the service is a singleton.
27 ///
28 /// The file cannot change without a deploy, which restarts the process, so the parse would be a
29 /// one-off if not for `dotnet watch`. Keying the copy on the file's last write time costs one stat
30 /// per request and picks up a refresh in development without a restart.
31 /// </remarks>
32 public sealed class WarframeDropService(
33 IOptions<WarframeOptions> options,
34 ILogger<WarframeDropService> logger)
35 {
36 private readonly WarframeOptions _options = options.Value;
37 private readonly SemaphoreSlim _gate = new(1, 1);
38
39 private DropTables? _tables;
40 private DateTimeOffset _parsedStamp;
41
42 /// <summary>
43 /// Resolved against the directory the binary sits in, not the content root and not the working
44 /// directory. Both of those default to wherever the process was started from, so a unit file
45 /// without a WorkingDirectory would send this looking in /. The drop tables ship next to the
46 /// assembly, so that is what to anchor to.
47 /// </summary>
48 private string Path => System.IO.Path.Combine(AppContext.BaseDirectory, _options.FilePath);
49
50 public async Task<DropTableStatus> GetTablesAsync(CancellationToken cancellationToken = default)
51 {
52 var file = new FileInfo(Path);
53 if (!file.Exists)
54 {
55 logger.LogError("The drop tables are missing from {Path}", Path);
56 return DropTableStatus.Failed($"The drop tables are missing from {_options.FilePath}.", _tables);
57 }
58
59 var stamp = new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero);
60 if (_tables is { } fresh && stamp == _parsedStamp)
61 {
62 return DropTableStatus.Ready(fresh);
63 }
64
65 await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
66 try
67 {
68 // Someone else may have parsed the same file while this request queued for the gate.
69 if (_tables is { } current && stamp == _parsedStamp)
70 {
71 return DropTableStatus.Ready(current);
72 }
73
74 var tables = await ParseAsync(stamp, cancellationToken).ConfigureAwait(false);
75 _tables = tables;
76 _parsedStamp = stamp;
77 return DropTableStatus.Ready(tables);
78 }
79 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
80 {
81 logger.LogError(ex, "Could not read the drop tables from {Path}", Path);
82 return DropTableStatus.Failed($"Could not read the drop tables ({ex.Message})", _tables);
83 }
84 finally
85 {
86 _gate.Release();
87 }
88 }
89
90 private async Task<DropTables> ParseAsync(DateTimeOffset stamp, CancellationToken cancellationToken)
91 {
92 var stopwatch = Stopwatch.StartNew();
93 var html = await File.ReadAllTextAsync(Path, cancellationToken).ConfigureAwait(false);
94 var tables = DropTableParser.Parse(html, stamp);
95
96 logger.LogInformation(
97 "Read the Warframe drop tables ({Update}): {Bytes} bytes, {Items} items in {Elapsed} ms",
98 tables.LastUpdate, html.Length, tables.Names.Count, stopwatch.ElapsedMilliseconds);
99
100 return tables;
101 }
102 }