using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Options; using StellaOps.Scanner.WebService.Serialization; namespace StellaOps.Scanner.WebService.Services; internal sealed class RedisPlatformEventPublisher : IPlatformEventPublisher, IAsyncDisposable { private readonly ScannerWebServiceOptions.EventsOptions _options; private readonly ILogger _logger; private readonly IRedisConnectionFactory _connectionFactory; private readonly TimeSpan _publishTimeout; private readonly string _streamKey; private readonly long? _maxStreamLength; private readonly SemaphoreSlim _connectionGate = new(1, 1); private IConnectionMultiplexer? _connection; private bool _disposed; public RedisPlatformEventPublisher( IOptions options, IRedisConnectionFactory connectionFactory, ILogger logger) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(connectionFactory); _options = options.Value.Events ?? throw new InvalidOperationException("Events options are required when redis publisher is registered."); if (!_options.Enabled) { throw new InvalidOperationException("RedisPlatformEventPublisher requires events emission to be enabled."); } if (!string.Equals(_options.Driver, "redis", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"RedisPlatformEventPublisher cannot be used with driver '{_options.Driver}'."); } _connectionFactory = connectionFactory; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _streamKey = string.IsNullOrWhiteSpace(_options.Stream) ? "stella.events" : _options.Stream; _publishTimeout = TimeSpan.FromSeconds(_options.PublishTimeoutSeconds <= 0 ? 5 : _options.PublishTimeoutSeconds); _maxStreamLength = _options.MaxStreamLength > 0 ? _options.MaxStreamLength : null; } public async Task PublishAsync(OrchestratorEvent @event, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(@event); cancellationToken.ThrowIfCancellationRequested(); var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false); var payload = OrchestratorEventSerializer.Serialize(@event); var entries = new NameValueEntry[] { new("event", payload), new("kind", @event.Kind), new("tenant", @event.Tenant), new("occurredAt", @event.OccurredAt.ToString("O", CultureInfo.InvariantCulture)), new("idempotencyKey", @event.IdempotencyKey) }; int? maxLength = null; if (_maxStreamLength.HasValue) { var clamped = Math.Min(_maxStreamLength.Value, int.MaxValue); maxLength = (int)clamped; } var publishTask = maxLength.HasValue ? database.StreamAddAsync(_streamKey, entries, maxLength: maxLength, useApproximateMaxLength: true) : database.StreamAddAsync(_streamKey, entries); if (_publishTimeout > TimeSpan.Zero) { await publishTask.WaitAsync(_publishTimeout, cancellationToken).ConfigureAwait(false); } else { await publishTask.ConfigureAwait(false); } } private async Task GetDatabaseAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (_connection is not null && _connection.IsConnected) { return _connection.GetDatabase(); } await _connectionGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_connection is null || !_connection.IsConnected) { var config = ConfigurationOptions.Parse(_options.Dsn); config.AbortOnConnectFail = false; if (_options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName)) { config.ClientName = clientName; } if (_options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl)) { config.Ssl = ssl; } _connection = await _connectionFactory.ConnectAsync(config, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Connected Redis platform event publisher to stream {Stream}.", _streamKey); } } finally { _connectionGate.Release(); } return _connection!.GetDatabase(); } 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 while closing Redis platform event publisher connection."); } _connection.Dispose(); } _connectionGate.Dispose(); } }