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