Files
git.stella-ops.org/src/Signals/StellaOps.Signals/Services/RedisEventsPublisher.cs
StellaOps Bot bc0762e97d up
2025-12-09 00:20:52 +02:00

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