Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Scheduler.Queue.Redis;
internal interface IRedisSchedulerQueuePayload<TMessage>
{
string QueueName { get; }
string GetIdempotencyKey(TMessage message);
string Serialize(TMessage message);
TMessage Deserialize(string payload);
string GetRunId(TMessage message);
string GetTenantId(TMessage message);
string? GetScheduleId(TMessage message);
string? GetSegmentId(TMessage message);
string? GetCorrelationId(TMessage message);
IReadOnlyDictionary<string, string>? GetAttributes(TMessage message);
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerPlannerQueue
: RedisSchedulerQueueBase<PlannerQueueMessage>, ISchedulerPlannerQueue
{
public RedisSchedulerPlannerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerPlannerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Planner,
PlannerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class PlannerPayload : IRedisSchedulerQueuePayload<PlannerQueueMessage>
{
public static PlannerPayload Instance { get; } = new();
public string QueueName => "planner";
public string GetIdempotencyKey(PlannerQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(PlannerQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public PlannerQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<PlannerQueueMessage>(payload);
public string GetRunId(PlannerQueueMessage message)
=> message.Run.Id;
public string GetTenantId(PlannerQueueMessage message)
=> message.Run.TenantId;
public string? GetScheduleId(PlannerQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(PlannerQueueMessage message)
=> null;
public string? GetCorrelationId(PlannerQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(PlannerQueueMessage message)
=> null;
}
}

View File

@@ -0,0 +1,805 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace StellaOps.Scheduler.Queue.Redis;
internal abstract class RedisSchedulerQueueBase<TMessage> : ISchedulerQueue<TMessage>, IAsyncDisposable, ISchedulerQueueTransportDiagnostics
{
private const string TransportName = "redis";
private readonly SchedulerQueueOptions _queueOptions;
private readonly SchedulerRedisQueueOptions _redisOptions;
private readonly RedisSchedulerStreamOptions _streamOptions;
private readonly IRedisSchedulerQueuePayload<TMessage> _payload;
private readonly ILogger _logger;
private readonly TimeProvider _timeProvider;
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
private long _approximateDepth;
private IConnectionMultiplexer? _connection;
private volatile bool _groupInitialized;
private bool _disposed;
protected RedisSchedulerQueueBase(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
RedisSchedulerStreamOptions streamOptions,
IRedisSchedulerQueuePayload<TMessage> payload,
ILogger logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
_streamOptions = streamOptions ?? throw new ArgumentNullException(nameof(streamOptions));
_payload = payload ?? throw new ArgumentNullException(nameof(payload));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
{
throw new InvalidOperationException("Redis connection string must be configured for the scheduler queue.");
}
if (string.IsNullOrWhiteSpace(_streamOptions.Stream))
{
throw new InvalidOperationException("Redis stream name must be configured for the scheduler queue.");
}
if (string.IsNullOrWhiteSpace(_streamOptions.ConsumerGroup))
{
throw new InvalidOperationException("Redis consumer group must be configured for the scheduler queue.");
}
}
public async ValueTask<SchedulerQueueEnqueueResult> EnqueueAsync(
TMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(database, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var attempt = 1;
var entries = BuildEntries(message, now, attempt);
var messageId = await AddToStreamAsync(
database,
_streamOptions.Stream,
entries,
_streamOptions.ApproximateMaxLength,
_streamOptions.ApproximateMaxLength is not null)
.ConfigureAwait(false);
var idempotencyKey = BuildIdempotencyKey(_payload.GetIdempotencyKey(message));
var stored = await database.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _streamOptions.IdempotencyWindow)
.ConfigureAwait(false);
if (!stored)
{
await database.StreamDeleteAsync(_streamOptions.Stream, new RedisValue[] { messageId }).ConfigureAwait(false);
var existing = await database.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var reusable = existing.IsNullOrEmpty ? messageId : existing;
SchedulerQueueMetrics.RecordDeduplicated(TransportName, _payload.QueueName);
_logger.LogDebug(
"Duplicate enqueue detected for scheduler queue {Queue} with key {Key}; returning existing stream id {StreamId}.",
_payload.QueueName,
idempotencyKey,
reusable.ToString());
PublishDepth();
return new SchedulerQueueEnqueueResult(reusable.ToString(), true);
}
SchedulerQueueMetrics.RecordEnqueued(TransportName, _payload.QueueName);
_logger.LogDebug(
"Enqueued {Queue} message into {Stream} with id {StreamId}.",
_payload.QueueName,
_streamOptions.Stream,
messageId.ToString());
IncrementDepth();
return new SchedulerQueueEnqueueResult(messageId.ToString(), false);
}
public async ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> LeaseAsync(
SchedulerQueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(database, cancellationToken).ConfigureAwait(false);
var entries = await database.StreamReadGroupAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
request.Consumer,
position: ">",
count: request.BatchSize,
flags: CommandFlags.None)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
PublishDepth();
return Array.Empty<ISchedulerQueueLease<TMessage>>();
}
var now = _timeProvider.GetUtcNow();
var leases = new List<ISchedulerQueueLease<TMessage>>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(entry, request.Consumer, now, request.LeaseDuration, attemptOverride: null);
if (lease is null)
{
await HandlePoisonEntryAsync(database, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
PublishDepth();
return leases;
}
public async ValueTask<IReadOnlyList<ISchedulerQueueLease<TMessage>>> ClaimExpiredAsync(
SchedulerQueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(database, cancellationToken).ConfigureAwait(false);
var pending = await database.StreamPendingMessagesAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
options.BatchSize,
RedisValue.Null,
(long)options.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return Array.Empty<ISchedulerQueueLease<TMessage>>();
}
var eligible = pending
.Where(info => info.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
.ToArray();
if (eligible.Length == 0)
{
return Array.Empty<ISchedulerQueueLease<TMessage>>();
}
var messageIds = eligible
.Select(info => (RedisValue)info.MessageId)
.ToArray();
var claimed = await database.StreamClaimAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
options.ClaimantConsumer,
0,
messageIds,
CommandFlags.None)
.ConfigureAwait(false);
if (claimed is null || claimed.Length == 0)
{
PublishDepth();
return Array.Empty<ISchedulerQueueLease<TMessage>>();
}
var now = _timeProvider.GetUtcNow();
var attemptLookup = eligible.ToDictionary(
info => info.MessageId.IsNullOrEmpty ? string.Empty : info.MessageId.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
var leases = new List<ISchedulerQueueLease<TMessage>>(claimed.Length);
foreach (var entry in claimed)
{
var entryId = entry.Id.ToString();
attemptLookup.TryGetValue(entryId, out var attempt);
var lease = TryMapLease(
entry,
options.ClaimantConsumer,
now,
_queueOptions.DefaultLeaseDuration,
attemptOverride: attempt);
if (lease is null)
{
await HandlePoisonEntryAsync(database, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
PublishDepth();
return leases;
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.CloseAsync();
_connection.Dispose();
}
_connectionLock.Dispose();
_groupInitLock.Dispose();
SchedulerQueueMetrics.RemoveDepth(TransportName, _payload.QueueName);
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
RedisSchedulerQueueLease<TMessage> lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await database.StreamAcknowledgeAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_streamOptions.Stream,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
SchedulerQueueMetrics.RecordAck(TransportName, _payload.QueueName);
DecrementDepth();
}
internal async Task RenewLeaseAsync(
RedisSchedulerQueueLease<TMessage> lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await database.StreamClaimAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
lease.Consumer,
0,
new RedisValue[] { lease.MessageId },
CommandFlags.None)
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
}
internal async Task ReleaseAsync(
RedisSchedulerQueueLease<TMessage> lease,
SchedulerQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == SchedulerQueueReleaseDisposition.Retry
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
{
await DeadLetterAsync(
lease,
$"max-delivery-attempts:{lease.Attempt}",
cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await database.StreamAcknowledgeAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_streamOptions.Stream,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
SchedulerQueueMetrics.RecordAck(TransportName, _payload.QueueName);
DecrementDepth();
if (disposition == SchedulerQueueReleaseDisposition.Retry)
{
SchedulerQueueMetrics.RecordRetry(TransportName, _payload.QueueName);
lease.IncrementAttempt();
var backoff = CalculateBackoff(lease.Attempt);
if (backoff > TimeSpan.Zero)
{
try
{
await Task.Delay(backoff, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt);
await AddToStreamAsync(
database,
_streamOptions.Stream,
entries,
_streamOptions.ApproximateMaxLength,
_streamOptions.ApproximateMaxLength is not null)
.ConfigureAwait(false);
SchedulerQueueMetrics.RecordEnqueued(TransportName, _payload.QueueName);
IncrementDepth();
}
}
internal async Task DeadLetterAsync(
RedisSchedulerQueueLease<TMessage> lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await database.StreamAcknowledgeAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_streamOptions.Stream,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
DecrementDepth();
if (!_queueOptions.DeadLetterEnabled)
{
_logger.LogWarning(
"Dropped {Queue} message {MessageId} after {Attempt} attempt(s); dead-letter disabled. Reason: {Reason}",
_payload.QueueName,
lease.MessageId,
lease.Attempt,
reason);
return;
}
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt);
await AddToStreamAsync(
database,
_streamOptions.DeadLetterStream,
entries,
null,
false)
.ConfigureAwait(false);
SchedulerQueueMetrics.RecordDeadLetter(TransportName, _payload.QueueName);
_logger.LogError(
"Dead-lettered {Queue} message {MessageId} after {Attempt} attempt(s): {Reason}",
_payload.QueueName,
lease.MessageId,
lease.Attempt,
reason);
}
public async ValueTask PingAsync(CancellationToken cancellationToken)
{
var database = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await database.ExecuteAsync("PING").ConfigureAwait(false);
}
private string BuildIdempotencyKey(string key)
=> string.Concat(_streamOptions.IdempotencyKeyPrefix, key);
private TimeSpan CalculateBackoff(int attempt)
{
if (attempt <= 1)
{
return _queueOptions.RetryInitialBackoff > TimeSpan.Zero
? _queueOptions.RetryInitialBackoff
: TimeSpan.Zero;
}
var initial = _queueOptions.RetryInitialBackoff > TimeSpan.Zero
? _queueOptions.RetryInitialBackoff
: TimeSpan.Zero;
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
var max = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
? _queueOptions.RetryMaxBackoff
: initial;
var exponent = attempt - 1;
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
}
private async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null)
{
var config = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
config.AbortOnConnectFail = false;
config.ConnectTimeout = (int)_redisOptions.InitializationTimeout.TotalMilliseconds;
config.ConnectRetry = 3;
if (_redisOptions.Database is not null)
{
config.DefaultDatabase = _redisOptions.Database;
}
_connection = await _connectionFactory(config).ConfigureAwait(false);
}
}
finally
{
_connectionLock.Release();
}
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
private async Task EnsureConsumerGroupAsync(
IDatabase database,
CancellationToken cancellationToken)
{
if (_groupInitialized)
{
return;
}
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_groupInitialized)
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Group already exists.
}
_groupInitialized = true;
}
finally
{
_groupInitLock.Release();
}
}
private NameValueEntry[] BuildEntries(
TMessage message,
DateTimeOffset enqueuedAt,
int attempt)
{
var attributes = _payload.GetAttributes(message);
var attributeCount = attributes?.Count ?? 0;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(10 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(SchedulerQueueFields.QueueKind, _payload.QueueName);
entries[index++] = new NameValueEntry(SchedulerQueueFields.RunId, _payload.GetRunId(message));
entries[index++] = new NameValueEntry(SchedulerQueueFields.TenantId, _payload.GetTenantId(message));
var scheduleId = _payload.GetScheduleId(message);
if (!string.IsNullOrWhiteSpace(scheduleId))
{
entries[index++] = new NameValueEntry(SchedulerQueueFields.ScheduleId, scheduleId);
}
var segmentId = _payload.GetSegmentId(message);
if (!string.IsNullOrWhiteSpace(segmentId))
{
entries[index++] = new NameValueEntry(SchedulerQueueFields.SegmentId, segmentId);
}
var correlationId = _payload.GetCorrelationId(message);
if (!string.IsNullOrWhiteSpace(correlationId))
{
entries[index++] = new NameValueEntry(SchedulerQueueFields.CorrelationId, correlationId);
}
entries[index++] = new NameValueEntry(SchedulerQueueFields.IdempotencyKey, _payload.GetIdempotencyKey(message));
entries[index++] = new NameValueEntry(SchedulerQueueFields.Attempt, attempt);
entries[index++] = new NameValueEntry(SchedulerQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
entries[index++] = new NameValueEntry(SchedulerQueueFields.Payload, _payload.Serialize(message));
if (attributeCount > 0 && attributes is not null)
{
foreach (var kvp in attributes)
{
entries[index++] = new NameValueEntry(
SchedulerQueueFields.AttributePrefix + kvp.Key,
kvp.Value);
}
}
var result = entries.AsSpan(0, index).ToArray();
ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
return result;
}
private RedisSchedulerQueueLease<TMessage>? TryMapLease(
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? payload = null;
string? runId = null;
string? tenantId = null;
string? scheduleId = null;
string? segmentId = null;
string? correlationId = null;
string? idempotencyKey = null;
long? enqueuedAtUnix = null;
var attempt = attemptOverride ?? 1;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var field in entry.Values)
{
var name = field.Name.ToString();
var value = field.Value;
if (name.Equals(SchedulerQueueFields.Payload, StringComparison.Ordinal))
{
payload = value.ToString();
}
else if (name.Equals(SchedulerQueueFields.RunId, StringComparison.Ordinal))
{
runId = value.ToString();
}
else if (name.Equals(SchedulerQueueFields.TenantId, StringComparison.Ordinal))
{
tenantId = value.ToString();
}
else if (name.Equals(SchedulerQueueFields.ScheduleId, StringComparison.Ordinal))
{
scheduleId = NormalizeOptional(value.ToString());
}
else if (name.Equals(SchedulerQueueFields.SegmentId, StringComparison.Ordinal))
{
segmentId = NormalizeOptional(value.ToString());
}
else if (name.Equals(SchedulerQueueFields.CorrelationId, StringComparison.Ordinal))
{
correlationId = NormalizeOptional(value.ToString());
}
else if (name.Equals(SchedulerQueueFields.IdempotencyKey, StringComparison.Ordinal))
{
idempotencyKey = value.ToString();
}
else if (name.Equals(SchedulerQueueFields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(value.ToString(), out var unixMs))
{
enqueuedAtUnix = unixMs;
}
}
else if (name.Equals(SchedulerQueueFields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(value.ToString(), out var parsedAttempt))
{
attempt = attemptOverride.HasValue
? Math.Max(attemptOverride.Value, parsedAttempt)
: Math.Max(1, parsedAttempt);
}
}
else if (name.StartsWith(SchedulerQueueFields.AttributePrefix, StringComparison.Ordinal))
{
var key = name[SchedulerQueueFields.AttributePrefix.Length..];
attributes[key] = value.ToString();
}
}
if (payload is null || runId is null || tenantId is null || enqueuedAtUnix is null || idempotencyKey is null)
{
return null;
}
var message = _payload.Deserialize(payload);
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var leaseExpires = now.Add(leaseDuration);
IReadOnlyDictionary<string, string> attributeView = attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
return new RedisSchedulerQueueLease<TMessage>(
this,
entry.Id.ToString(),
idempotencyKey,
runId,
tenantId,
scheduleId,
segmentId,
correlationId,
attributeView,
message,
attempt,
enqueuedAt,
leaseExpires,
consumer);
}
private async Task HandlePoisonEntryAsync(IDatabase database, RedisValue entryId)
{
await database.StreamAcknowledgeAsync(
_streamOptions.Stream,
_streamOptions.ConsumerGroup,
new RedisValue[] { entryId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_streamOptions.Stream,
new RedisValue[] { entryId })
.ConfigureAwait(false);
}
private async Task<RedisValue> AddToStreamAsync(
IDatabase database,
RedisKey stream,
NameValueEntry[] entries,
int? maxLength,
bool useApproximateLength)
{
var capacity = 4 + (entries.Length * 2);
var args = new List<object>(capacity)
{
stream
};
if (maxLength.HasValue)
{
args.Add("MAXLEN");
if (useApproximateLength)
{
args.Add("~");
}
args.Add(maxLength.Value);
}
args.Add("*");
for (var i = 0; i < entries.Length; i++)
{
args.Add(entries[i].Name);
args.Add(entries[i].Value);
}
var result = await database.ExecuteAsync("XADD", args.ToArray()).ConfigureAwait(false);
return (RedisValue)result!;
}
private void IncrementDepth()
{
var depth = Interlocked.Increment(ref _approximateDepth);
SchedulerQueueMetrics.RecordDepth(TransportName, _payload.QueueName, depth);
}
private void DecrementDepth()
{
var depth = Interlocked.Decrement(ref _approximateDepth);
if (depth < 0)
{
depth = Interlocked.Exchange(ref _approximateDepth, 0);
}
SchedulerQueueMetrics.RecordDepth(TransportName, _payload.QueueName, depth);
}
private void PublishDepth()
{
var depth = Volatile.Read(ref _approximateDepth);
SchedulerQueueMetrics.RecordDepth(TransportName, _payload.QueueName, depth);
}
private static string? NormalizeOptional(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value;
}
private sealed class EmptyReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public static readonly IReadOnlyDictionary<TKey, TValue> Instance =
new ReadOnlyDictionary<TKey, TValue>(new Dictionary<TKey, TValue>(0, EqualityComparer<TKey>.Default));
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerQueueLease<TMessage> : ISchedulerQueueLease<TMessage>
{
private readonly RedisSchedulerQueueBase<TMessage> _queue;
private int _completed;
internal RedisSchedulerQueueLease(
RedisSchedulerQueueBase<TMessage> queue,
string messageId,
string idempotencyKey,
string runId,
string tenantId,
string? scheduleId,
string? segmentId,
string? correlationId,
IReadOnlyDictionary<string, string> attributes,
TMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer)
{
_queue = queue;
MessageId = messageId;
IdempotencyKey = idempotencyKey;
RunId = runId;
TenantId = tenantId;
ScheduleId = scheduleId;
SegmentId = segmentId;
CorrelationId = correlationId;
Attributes = attributes;
Message = message;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
}
public string MessageId { get; }
public string IdempotencyKey { get; }
public string RunId { get; }
public string TenantId { get; }
public string? ScheduleId { get; }
public string? SegmentId { get; }
public string? CorrelationId { get; }
public IReadOnlyDictionary<string, string> Attributes { get; }
public TMessage Message { get; }
public int Attempt { get; private set; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public Task AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
public Task RenewAsync(TimeSpan leaseDuration, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, leaseDuration, cancellationToken);
public Task ReleaseAsync(SchedulerQueueReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
public Task DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Queue.Redis;
internal sealed class RedisSchedulerRunnerQueue
: RedisSchedulerQueueBase<RunnerSegmentQueueMessage>, ISchedulerRunnerQueue
{
public RedisSchedulerRunnerQueue(
SchedulerQueueOptions queueOptions,
SchedulerRedisQueueOptions redisOptions,
ILogger<RedisSchedulerRunnerQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
: base(
queueOptions,
redisOptions,
redisOptions.Runner,
RunnerPayload.Instance,
logger,
timeProvider,
connectionFactory)
{
}
private sealed class RunnerPayload : IRedisSchedulerQueuePayload<RunnerSegmentQueueMessage>
{
public static RunnerPayload Instance { get; } = new();
public string QueueName => "runner";
public string GetIdempotencyKey(RunnerSegmentQueueMessage message)
=> message.IdempotencyKey;
public string Serialize(RunnerSegmentQueueMessage message)
=> CanonicalJsonSerializer.Serialize(message);
public RunnerSegmentQueueMessage Deserialize(string payload)
=> CanonicalJsonSerializer.Deserialize<RunnerSegmentQueueMessage>(payload);
public string GetRunId(RunnerSegmentQueueMessage message)
=> message.RunId;
public string GetTenantId(RunnerSegmentQueueMessage message)
=> message.TenantId;
public string? GetScheduleId(RunnerSegmentQueueMessage message)
=> message.ScheduleId;
public string? GetSegmentId(RunnerSegmentQueueMessage message)
=> message.SegmentId;
public string? GetCorrelationId(RunnerSegmentQueueMessage message)
=> message.CorrelationId;
public IReadOnlyDictionary<string, string>? GetAttributes(RunnerSegmentQueueMessage message)
{
if (message.Attributes.Count == 0 && message.ImageDigests.Count == 0)
{
return null;
}
// Ensure digests remain accessible without deserializing the entire payload.
var map = new Dictionary<string, string>(message.Attributes, StringComparer.Ordinal);
map["imageDigestCount"] = message.ImageDigests.Count.ToString();
// populate first few digests for quick inspection (bounded)
var take = Math.Min(message.ImageDigests.Count, 5);
for (var i = 0; i < take; i++)
{
map[$"digest{i}"] = message.ImageDigests[i];
}
if (message.RatePerSecond.HasValue)
{
map["ratePerSecond"] = message.RatePerSecond.Value.ToString();
}
map["usageOnly"] = message.UsageOnly ? "true" : "false";
return map;
}
}
}