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 Microsoft.Extensions.Options; using StackExchange.Redis; using StellaOps.Scheduler.WebService.Options; namespace StellaOps.Scheduler.WebService.GraphJobs.Events; internal sealed class GraphJobEventPublisher : IGraphJobCompletionPublisher, IAsyncDisposable { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly IOptionsMonitor _options; private readonly IRedisConnectionFactory _connectionFactory; private readonly ILogger _logger; private readonly SemaphoreSlim _connectionGate = new(1, 1); private IConnectionMultiplexer? _connection; private bool _disposed; public GraphJobEventPublisher( IOptionsMonitor options, IRedisConnectionFactory connectionFactory, ILogger logger) { _options = options ?? throw new ArgumentNullException(nameof(options)); _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task PublishAsync(GraphJobCompletionNotification notification, CancellationToken cancellationToken) { if (notification is null) { throw new ArgumentNullException(nameof(notification)); } var options = _options.CurrentValue?.GraphJobs ?? new GraphJobEventsOptions(); if (!options.Enabled) { _logger.LogDebug("Graph job events disabled; skipping emission for {JobId}.", notification.Job.Id); return; } if (!string.Equals(options.Driver, "redis", StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning( "Graph job events configured with unsupported driver '{Driver}'. Falling back to logging.", options.Driver); LogEnvelope(notification); return; } try { var database = await GetDatabaseAsync(options, cancellationToken).ConfigureAwait(false); var envelope = GraphJobEventFactory.Create(notification); var payload = JsonSerializer.Serialize(envelope, SerializerOptions); var entries = new[] { new NameValueEntry("event", payload), new NameValueEntry("kind", envelope.Kind), new NameValueEntry("tenant", envelope.Tenant), new NameValueEntry("occurredAt", envelope.Timestamp.ToString("O", CultureInfo.InvariantCulture)), new NameValueEntry("jobId", notification.Job.Id), new NameValueEntry("status", notification.Status.ToString()) }; var streamKey = string.IsNullOrWhiteSpace(options.Stream) ? "stella.events" : options.Stream; var publishTask = CreatePublishTask(database, streamKey, entries, options.MaxStreamLength); if (options.PublishTimeoutSeconds > 0) { var timeout = TimeSpan.FromSeconds(options.PublishTimeoutSeconds); await publishTask.WaitAsync(timeout, cancellationToken).ConfigureAwait(false); } else { await publishTask.ConfigureAwait(false); } _logger.LogDebug("Published graph job event {JobId} to stream {Stream}.", notification.Job.Id, streamKey); } catch (Exception ex) { _logger.LogError(ex, "Failed to publish graph job completion for {JobId}; logging payload instead.", notification.Job.Id); LogEnvelope(notification); } } private Task CreatePublishTask(IDatabase database, string streamKey, NameValueEntry[] entries, long maxStreamLength) { if (maxStreamLength > 0) { var clamped = (int)Math.Min(maxStreamLength, int.MaxValue); return database.StreamAddAsync(streamKey, entries, maxLength: clamped, useApproximateMaxLength: true); } return database.StreamAddAsync(streamKey, entries); } private async Task GetDatabaseAsync(GraphJobEventsOptions options, 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.Dsn); configuration.AbortOnConnectFail = false; if (options.DriverSettings.TryGetValue("clientName", out var clientName) && !string.IsNullOrWhiteSpace(clientName)) { configuration.ClientName = clientName; } if (options.DriverSettings.TryGetValue("ssl", out var sslValue) && bool.TryParse(sslValue, out var ssl)) { configuration.Ssl = ssl; } if (options.DriverSettings.TryGetValue("password", out var password) && !string.IsNullOrWhiteSpace(password)) { configuration.Password = password; } _connection = await _connectionFactory.ConnectAsync(configuration, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Connected graph job publisher to Redis stream {Stream}.", options.Stream); } } finally { _connectionGate.Release(); } return _connection!.GetDatabase(); } private void LogEnvelope(GraphJobCompletionNotification notification) { var envelope = GraphJobEventFactory.Create(notification); var json = JsonSerializer.Serialize(envelope, SerializerOptions); _logger.LogInformation("{EventJson}", json); } 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 graph job Redis connection."); } _connection.Dispose(); } _connectionGate.Dispose(); } }