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