up
This commit is contained in:
185
src/Signals/StellaOps.Signals/Services/RedisEventsPublisher.cs
Normal file
185
src/Signals/StellaOps.Signals/Services/RedisEventsPublisher.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user