Blog/Services/RvrbService.cs 6.3 K · 154 lines · raw · history

1 using System.Net.Sockets;
2 using BeamSharp.Node;
3 using Blog.Models;
4 using Microsoft.Extensions.Caching.Hybrid;
5 using Microsoft.Extensions.Options;
6
7 namespace Blog.Services;
8
9 /// <summary>Where the rvrb bot is, and how patient to be with it.</summary>
10 public sealed class RvrbOptions
11 {
12 public const string Section = "Rvrb";
13
14 /// <summary>The bot's node, as <c>name@host</c>. It runs on this machine, hence loopback.</summary>
15 public string Node { get; set; } = "rvrb@127.0.0.1";
16
17 /// <summary>The name this site registers with EPMD under.</summary>
18 public string LocalNode { get; set; } = "blog@127.0.0.1";
19
20 /// <summary>
21 /// The Erlang cookie both nodes share. Not in appsettings.json — it is the only thing standing
22 /// between a local process and the bot's node, so it comes from user secrets in development and
23 /// from the environment (<c>Rvrb__Cookie</c>) in production.
24 /// </summary>
25 public string? Cookie { get; set; }
26
27 /// <summary>How long to wait for the bot to answer before calling it unreachable.</summary>
28 public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5);
29
30 /// <summary>
31 /// How long a snapshot is served to everyone who asks. The live half moves in seconds, so this
32 /// is short — but it is what keeps a refresh loop, or a crawler, from turning into one
33 /// round trip and a handful of queries per request.
34 /// </summary>
35 public TimeSpan CacheFor { get; set; } = TimeSpan.FromSeconds(15);
36 }
37
38 /// <summary>
39 /// Reads the rvrb bot's stats over Erlang distribution.
40 /// </summary>
41 /// <remarks>
42 /// The site joins the cluster as a hidden Erlang node of its own — BeamSharp speaks the
43 /// distribution protocol, so from the bot's side this is an ordinary <c>:rpc.call/4</c> arriving
44 /// from a peer, and nothing on the Elixir side had to grow an HTTP endpoint for it.
45 ///
46 /// The node is started on the first request rather than at boot, deliberately: registering with
47 /// EPMD can fail (it isn't running, the bot was deployed without distribution), and the rest of
48 /// this site has no business failing to start over a page that shows a bot's play count. A failure
49 /// here is a value, not an exception — see <see cref="RvrbStatus"/>.
50 /// </remarks>
51 public sealed class RvrbService(
52 HybridCache cache,
53 IOptions<RvrbOptions> options,
54 ILogger<RvrbService> logger) : IAsyncDisposable
55 {
56 private readonly RvrbOptions _options = options.Value;
57 private readonly SemaphoreSlim _startGate = new(1, 1);
58 private ErlangNode? _node;
59
60 public async ValueTask<RvrbStatus> GetStatusAsync(CancellationToken cancellationToken = default)
61 {
62 return await cache.GetOrCreateAsync(
63 "rvrb/snapshot",
64 this,
65 static (service, cancel) => service.FetchAsync(cancel),
66 new HybridCacheEntryOptions
67 {
68 Expiration = _options.CacheFor,
69 LocalCacheExpiration = _options.CacheFor
70 },
71 cancellationToken: cancellationToken)
72 .ConfigureAwait(false);
73 }
74
75 private async ValueTask<RvrbStatus> FetchAsync(CancellationToken cancellationToken)
76 {
77 try
78 {
79 var node = await StartedNodeAsync(cancellationToken).ConfigureAwait(false);
80
81 // Elixir modules are atoms prefixed with `Elixir.`, which is what `Rvrb.Stats` is on
82 // the wire. `snapshot/0` exists because its options argument has a default.
83 var reply = await node
84 .RpcAsync(_options.Node, "Elixir.Rvrb.Stats", "snapshot", [], _options.CallTimeout,
85 cancellationToken)
86 .ConfigureAwait(false);
87
88 return RvrbStatus.Reachable(RvrbSnapshotReader.Read(reply));
89 }
90 catch (Exception ex) when (ex is IOException or SocketException or TimeoutException
91 or ErlangRpcException or ErlangExitException
92 or InvalidOperationException or FormatException)
93 {
94 logger.LogWarning(ex, "could not read stats from {Node}", _options.Node);
95 return RvrbStatus.Unreachable(Describe(ex));
96 }
97 }
98
99 /// <summary>
100 /// This site's own node, started on first use. A start that fails leaves nothing behind, so the
101 /// next request tries again — which is what recovers the page once the bot comes back.
102 /// </summary>
103 private async ValueTask<ErlangNode> StartedNodeAsync(CancellationToken cancellationToken)
104 {
105 if (_node is { } running) return running;
106
107 await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false);
108 try
109 {
110 if (_node is { } started) return started;
111
112 var node = new ErlangNode(_options.LocalNode, new ErlangNodeOptions
113 {
114 Cookie = _options.Cookie,
115 // Both nodes are on this machine, so neither the listener nor the lookup has any
116 // business leaving it. Erlang distribution authenticates with a shared cookie and
117 // then sends everything in the clear.
118 BindAddress = "127.0.0.1",
119 EpmdHost = "127.0.0.1",
120 Log = line => logger.LogDebug("beamsharp: {Message}", line)
121 });
122
123 try
124 {
125 await node.StartAsync(cancellationToken).ConfigureAwait(false);
126 }
127 catch
128 {
129 // StartAsync binds the listener before it registers with EPMD, so a failure after
130 // that point would otherwise leak a socket per attempt.
131 await node.DisposeAsync().ConfigureAwait(false);
132 throw;
133 }
134
135 _node = node;
136 return node;
137 }
138 finally
139 {
140 _startGate.Release();
141 }
142 }
143
144 // The useful half of these is usually the inner exception: BeamSharp reports an unknown node,
145 // a bad cookie and a refused connection as one IOException carrying the reason underneath.
146 private static string Describe(Exception ex) =>
147 ex.InnerException is { } inner ? $"{ex.Message}: {inner.Message}" : ex.Message;
148
149 public async ValueTask DisposeAsync()
150 {
151 if (_node is { } node) await node.DisposeAsync().ConfigureAwait(false);
152 _startGate.Dispose();
153 }
154 }