Files
git.stella-ops.org/src/Scheduler/StellaOps.Scheduler.WebService/GraphJobs/Events/GraphJobEventPublisher.cs

187 lines
6.8 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 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<SchedulerEventsOptions> _options;
private readonly IRedisConnectionFactory _connectionFactory;
private readonly ILogger<GraphJobEventPublisher> _logger;
private readonly SemaphoreSlim _connectionGate = new(1, 1);
private IConnectionMultiplexer? _connection;
private bool _disposed;
public GraphJobEventPublisher(
IOptionsMonitor<SchedulerEventsOptions> options,
IRedisConnectionFactory connectionFactory,
ILogger<GraphJobEventPublisher> 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<RedisValue> 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<IDatabase> 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();
}
}