186 lines
6.4 KiB
C#
186 lines
6.4 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using StackExchange.Redis;
|
|
using StellaOps.Signals.Models;
|
|
using StellaOps.Signals.Options;
|
|
|
|
namespace StellaOps.Signals.Services;
|
|
|
|
internal sealed class RedisEventsPublisher : IEventsPublisher, IAsyncDisposable
|
|
{
|
|
private readonly SignalsEventsOptions options;
|
|
private readonly ILogger<RedisEventsPublisher> logger;
|
|
private readonly IRedisConnectionFactory connectionFactory;
|
|
private readonly ReachabilityFactEventBuilder eventBuilder;
|
|
private readonly TimeSpan publishTimeout;
|
|
private readonly int? maxStreamLength;
|
|
private readonly SemaphoreSlim connectionGate = new(1, 1);
|
|
private IConnectionMultiplexer? connection;
|
|
private bool disposed;
|
|
|
|
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = false,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
public RedisEventsPublisher(
|
|
SignalsOptions options,
|
|
IRedisConnectionFactory connectionFactory,
|
|
ReachabilityFactEventBuilder eventBuilder,
|
|
ILogger<RedisEventsPublisher> logger)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
this.options = options.Events ?? throw new InvalidOperationException("Signals events configuration is required.");
|
|
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
|
this.eventBuilder = eventBuilder ?? throw new ArgumentNullException(nameof(eventBuilder));
|
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
publishTimeout = this.options.PublishTimeoutSeconds > 0
|
|
? TimeSpan.FromSeconds(this.options.PublishTimeoutSeconds)
|
|
: TimeSpan.Zero;
|
|
maxStreamLength = this.options.MaxStreamLength > 0
|
|
? (int)Math.Min(this.options.MaxStreamLength, int.MaxValue)
|
|
: null;
|
|
}
|
|
|
|
public async Task PublishFactUpdatedAsync(ReachabilityFactDocument fact, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(fact);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (!options.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var envelope = eventBuilder.Build(fact);
|
|
var json = JsonSerializer.Serialize(envelope, SerializerOptions);
|
|
|
|
try
|
|
{
|
|
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var entries = new[]
|
|
{
|
|
new NameValueEntry("event", json),
|
|
new NameValueEntry("event_id", envelope.EventId),
|
|
new NameValueEntry("subject_key", envelope.SubjectKey),
|
|
new NameValueEntry("digest", envelope.Digest),
|
|
new NameValueEntry("fact_version", envelope.FactVersion.ToString(CultureInfo.InvariantCulture))
|
|
};
|
|
|
|
var publishTask = maxStreamLength.HasValue
|
|
? database.StreamAddAsync(options.Stream, entries, maxLength: maxStreamLength, useApproximateMaxLength: true)
|
|
: database.StreamAddAsync(options.Stream, entries);
|
|
|
|
if (publishTimeout > TimeSpan.Zero)
|
|
{
|
|
await publishTask.WaitAsync(publishTimeout, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await publishTask.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Failed to publish reachability event to Redis stream {Stream}.", options.Stream);
|
|
await TryPublishDeadLetterAsync(json, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (connection is { IsConnected: true })
|
|
{
|
|
return connection.GetDatabase();
|
|
}
|
|
|
|
await connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (connection is null || !connection.IsConnected)
|
|
{
|
|
var configuration = ConfigurationOptions.Parse(options.ConnectionString!);
|
|
configuration.AbortOnConnectFail = false;
|
|
connection = await connectionFactory.ConnectAsync(configuration, cancellationToken).ConfigureAwait(false);
|
|
logger.LogInformation("Connected Signals events publisher to Redis stream {Stream}.", options.Stream);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
connectionGate.Release();
|
|
}
|
|
|
|
return connection!.GetDatabase();
|
|
}
|
|
|
|
private async Task TryPublishDeadLetterAsync(string json, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(options.DeadLetterStream) || connection is null || !connection.IsConnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var db = connection.GetDatabase();
|
|
var entries = new[]
|
|
{
|
|
new NameValueEntry("event", json),
|
|
new NameValueEntry("error", "publish-failed")
|
|
};
|
|
|
|
var dlqTask = maxStreamLength.HasValue
|
|
? db.StreamAddAsync(options.DeadLetterStream, entries, maxLength: maxStreamLength, useApproximateMaxLength: true)
|
|
: db.StreamAddAsync(options.DeadLetterStream, entries);
|
|
|
|
if (publishTimeout > TimeSpan.Zero)
|
|
{
|
|
await dlqTask.WaitAsync(publishTimeout, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
await dlqTask.ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to publish reachability event to DLQ stream {Stream}.", options.DeadLetterStream);
|
|
}
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
if (connection is not null)
|
|
{
|
|
try
|
|
{
|
|
await connection.CloseAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogDebug(ex, "Error closing Redis events publisher connection.");
|
|
}
|
|
|
|
connection.Dispose();
|
|
}
|
|
|
|
connectionGate.Dispose();
|
|
}
|
|
}
|