using System.Net.Sockets; using BeamSharp.Node; using Blog.Models; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Options; namespace Blog.Services; /// Where the rvrb bot is, and how patient to be with it. public sealed class RvrbOptions { public const string Section = "Rvrb"; /// The bot's node, as name@host. It runs on this machine, hence loopback. public string Node { get; set; } = "rvrb@127.0.0.1"; /// The name this site registers with EPMD under. public string LocalNode { get; set; } = "blog@127.0.0.1"; /// /// The Erlang cookie both nodes share. Not in appsettings.json — it is the only thing standing /// between a local process and the bot's node, so it comes from user secrets in development and /// from the environment (Rvrb__Cookie) in production. /// public string? Cookie { get; set; } /// How long to wait for the bot to answer before calling it unreachable. public TimeSpan CallTimeout { get; set; } = TimeSpan.FromSeconds(5); /// /// How long a snapshot is served to everyone who asks. The live half moves in seconds, so this /// is short — but it is what keeps a refresh loop, or a crawler, from turning into one /// round trip and a handful of queries per request. /// public TimeSpan CacheFor { get; set; } = TimeSpan.FromSeconds(15); } /// /// Reads the rvrb bot's stats over Erlang distribution. /// /// /// The site joins the cluster as a hidden Erlang node of its own — BeamSharp speaks the /// distribution protocol, so from the bot's side this is an ordinary :rpc.call/4 arriving /// from a peer, and nothing on the Elixir side had to grow an HTTP endpoint for it. /// /// The node is started on the first request rather than at boot, deliberately: registering with /// EPMD can fail (it isn't running, the bot was deployed without distribution), and the rest of /// this site has no business failing to start over a page that shows a bot's play count. A failure /// here is a value, not an exception — see . /// public sealed class RvrbService( HybridCache cache, IOptions options, ILogger logger) : IAsyncDisposable { private readonly RvrbOptions _options = options.Value; private readonly SemaphoreSlim _startGate = new(1, 1); private ErlangNode? _node; public async ValueTask GetStatusAsync(CancellationToken cancellationToken = default) { return await cache.GetOrCreateAsync( "rvrb/snapshot", this, static (service, cancel) => service.FetchAsync(cancel), new HybridCacheEntryOptions { Expiration = _options.CacheFor, LocalCacheExpiration = _options.CacheFor }, cancellationToken: cancellationToken) .ConfigureAwait(false); } private async ValueTask FetchAsync(CancellationToken cancellationToken) { try { var node = await StartedNodeAsync(cancellationToken).ConfigureAwait(false); // Elixir modules are atoms prefixed with `Elixir.`, which is what `Rvrb.Stats` is on // the wire. `snapshot/0` exists because its options argument has a default. var reply = await node .RpcAsync(_options.Node, "Elixir.Rvrb.Stats", "snapshot", [], _options.CallTimeout, cancellationToken) .ConfigureAwait(false); return RvrbStatus.Reachable(RvrbSnapshotReader.Read(reply)); } catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or ErlangRpcException or ErlangExitException or InvalidOperationException or FormatException) { logger.LogWarning(ex, "could not read stats from {Node}", _options.Node); return RvrbStatus.Unreachable(Describe(ex)); } } /// /// This site's own node, started on first use. A start that fails leaves nothing behind, so the /// next request tries again — which is what recovers the page once the bot comes back. /// private async ValueTask StartedNodeAsync(CancellationToken cancellationToken) { if (_node is { } running) return running; await _startGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_node is { } started) return started; var node = new ErlangNode(_options.LocalNode, new ErlangNodeOptions { Cookie = _options.Cookie, // Both nodes are on this machine, so neither the listener nor the lookup has any // business leaving it. Erlang distribution authenticates with a shared cookie and // then sends everything in the clear. BindAddress = "127.0.0.1", EpmdHost = "127.0.0.1", Log = line => logger.LogDebug("beamsharp: {Message}", line) }); try { await node.StartAsync(cancellationToken).ConfigureAwait(false); } catch { // StartAsync binds the listener before it registers with EPMD, so a failure after // that point would otherwise leak a socket per attempt. await node.DisposeAsync().ConfigureAwait(false); throw; } _node = node; return node; } finally { _startGate.Release(); } } // The useful half of these is usually the inner exception: BeamSharp reports an unknown node, // a bad cookie and a refused connection as one IOException carrying the reason underneath. private static string Describe(Exception ex) => ex.InnerException is { } inner ? $"{ex.Message}: {inner.Message}" : ex.Message; public async ValueTask DisposeAsync() { if (_node is { } node) await node.DisposeAsync().ConfigureAwait(false); _startGate.Dispose(); } }