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,766 @@
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.Scanner.Queue.Redis;
internal sealed class RedisScanQueue : IScanQueue, IAsyncDisposable
{
private const string TransportName = "redis";
private readonly ScannerQueueOptions _queueOptions;
private readonly RedisQueueOptions _options;
private readonly ILogger<RedisScanQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private IConnectionMultiplexer? _connection;
private volatile bool _groupInitialized;
private bool _disposed;
private string BuildIdempotencyKey(string key)
=> string.Concat(_options.IdempotencyKeyPrefix, key);
public RedisScanQueue(
ScannerQueueOptions queueOptions,
RedisQueueOptions options,
ILogger<RedisScanQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
{
throw new InvalidOperationException("Redis connection string must be configured for the scanner queue.");
}
}
public async ValueTask<QueueEnqueueResult> EnqueueAsync(
ScanQueueMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var attempt = 1;
var entries = BuildEntries(message, now, attempt);
var messageId = await AddToStreamAsync(
db,
_options.StreamName,
entries,
_options.ApproximateMaxLength,
_options.ApproximateMaxLength is not null)
.ConfigureAwait(false);
var idempotencyToken = message.IdempotencyKey ?? message.JobId;
var idempotencyKey = BuildIdempotencyKey(idempotencyToken);
var stored = await db.StringSetAsync(
key: idempotencyKey,
value: messageId,
when: When.NotExists,
expiry: _options.IdempotencyWindow)
.ConfigureAwait(false);
if (!stored)
{
// Duplicate enqueue delete the freshly added entry and surface cached ID.
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { messageId })
.ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
_logger.LogDebug(
"Duplicate queue enqueue detected for job {JobId} (token {Token}), returning existing stream id {StreamId}.",
message.JobId,
idempotencyToken,
duplicateId.ToString());
QueueMetrics.RecordDeduplicated(TransportName);
return new QueueEnqueueResult(duplicateId.ToString()!, true);
}
_logger.LogDebug(
"Enqueued job {JobId} into stream {Stream} with id {StreamId}.",
message.JobId,
_options.StreamName,
messageId.ToString());
QueueMetrics.RecordEnqueued(TransportName);
return new QueueEnqueueResult(messageId.ToString()!, false);
}
public async ValueTask<IReadOnlyList<IScanQueueLease>> LeaseAsync(
QueueLeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var entries = await db.StreamReadGroupAsync(
_options.StreamName,
_options.ConsumerGroup,
request.Consumer,
position: ">",
count: request.BatchSize,
flags: CommandFlags.None)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<IScanQueueLease>();
}
var now = _timeProvider.GetUtcNow();
var leases = new List<IScanQueueLease>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(
entry,
request.Consumer,
now,
request.LeaseDuration,
default);
if (lease is null)
{
_logger.LogWarning(
"Stream entry {StreamId} is missing required metadata; acknowledging to avoid poison message.",
entry.Id.ToString());
await db.StreamAcknowledgeAsync(
_options.StreamName,
_options.ConsumerGroup,
new RedisValue[] { entry.Id })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { entry.Id })
.ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask<IReadOnlyList<IScanQueueLease>> ClaimExpiredLeasesAsync(
QueueClaimOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var pending = await db.StreamPendingMessagesAsync(
_options.StreamName,
_options.ConsumerGroup,
options.BatchSize,
RedisValue.Null,
(long)options.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return Array.Empty<IScanQueueLease>();
}
var eligible = pending
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
.ToArray();
if (eligible.Length == 0)
{
return Array.Empty<IScanQueueLease>();
}
var messageIds = eligible
.Select(static p => (RedisValue)p.MessageId)
.ToArray();
var entries = await db.StreamClaimAsync(
_options.StreamName,
_options.ConsumerGroup,
options.ClaimantConsumer,
0,
messageIds,
CommandFlags.None)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<IScanQueueLease>();
}
var now = _timeProvider.GetUtcNow();
var pendingById = Enumerable.ToDictionary<StreamPendingMessageInfo, string, StreamPendingMessageInfo>(
eligible,
static p => p.MessageId.IsNullOrEmpty ? string.Empty : p.MessageId.ToString(),
static p => p,
StringComparer.Ordinal);
var leases = new List<IScanQueueLease>(entries.Length);
foreach (var entry in entries)
{
var entryIdValue = entry.Id;
var entryId = entryIdValue.IsNullOrEmpty ? string.Empty : entryIdValue.ToString();
var hasPending = pendingById.TryGetValue(entryId, out var pendingInfo);
var attempt = hasPending
? (int)Math.Max(1, pendingInfo.DeliveryCount)
: 1;
var lease = TryMapLease(
entry,
options.ClaimantConsumer,
now,
_queueOptions.DefaultLeaseDuration,
attempt);
if (lease is null)
{
_logger.LogWarning(
"Unable to map claimed stream entry {StreamId}; acknowledging to unblock queue.",
entry.Id.ToString());
await db.StreamAcknowledgeAsync(
_options.StreamName,
_options.ConsumerGroup,
new RedisValue[] { entry.Id })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { entry.Id })
.ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
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();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
RedisScanQueueLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_options.StreamName,
_options.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
_logger.LogDebug(
"Acknowledged job {JobId} ({MessageId}) on consumer {Consumer}.",
lease.JobId,
lease.MessageId,
lease.Consumer);
QueueMetrics.RecordAck(TransportName);
}
internal async Task RenewLeaseAsync(
RedisScanQueueLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamClaimAsync(
_options.StreamName,
_options.ConsumerGroup,
lease.Consumer,
0,
new RedisValue[] { lease.MessageId },
CommandFlags.None)
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed lease for job {JobId} until {LeaseExpiry:u}.",
lease.JobId,
expires);
}
internal async Task ReleaseAsync(
RedisScanQueueLease lease,
QueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == QueueReleaseDisposition.Retry
&& lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
{
_logger.LogWarning(
"Job {JobId} reached max delivery attempts ({Attempts}); moving to dead-letter.",
lease.JobId,
lease.Attempt);
await DeadLetterAsync(
lease,
$"max-delivery-attempts:{lease.Attempt}",
cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_options.StreamName,
_options.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
QueueMetrics.RecordAck(TransportName);
if (disposition == QueueReleaseDisposition.Retry)
{
QueueMetrics.RecordRetry(TransportName);
var delay = CalculateBackoff(lease.Attempt);
if (delay > TimeSpan.Zero)
{
_logger.LogDebug(
"Delaying retry for job {JobId} by {Delay} (attempt {Attempt}).",
lease.JobId,
delay,
lease.Attempt);
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
var requeueMessage = new ScanQueueMessage(lease.JobId, lease.Payload)
{
IdempotencyKey = lease.IdempotencyKey,
Attributes = lease.Attributes,
TraceId = lease.TraceId
};
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(requeueMessage, now, lease.Attempt + 1);
await AddToStreamAsync(
db,
_options.StreamName,
entries,
_options.ApproximateMaxLength,
_options.ApproximateMaxLength is not null)
.ConfigureAwait(false);
QueueMetrics.RecordEnqueued(TransportName);
_logger.LogWarning(
"Released job {JobId} for retry (attempt {Attempt}).",
lease.JobId,
lease.Attempt + 1);
}
else
{
_logger.LogInformation(
"Abandoned job {JobId} after {Attempt} attempt(s).",
lease.JobId,
lease.Attempt);
}
}
internal async Task DeadLetterAsync(
RedisScanQueueLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_options.StreamName,
_options.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_options.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(
new ScanQueueMessage(lease.JobId, lease.Payload)
{
IdempotencyKey = lease.IdempotencyKey,
Attributes = lease.Attributes,
TraceId = lease.TraceId
},
now,
lease.Attempt);
await AddToStreamAsync(
db,
_queueOptions.DeadLetter.StreamName,
entries,
null,
false)
.ConfigureAwait(false);
_logger.LogError(
"Dead-lettered job {JobId} (attempt {Attempt}): {Reason}",
lease.JobId,
lease.Attempt,
reason);
QueueMetrics.RecordDeadLetter(TransportName);
}
private async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
if (_connection is not null)
{
return _connection.GetDatabase(_options.Database ?? -1);
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null)
{
var config = ConfigurationOptions.Parse(_options.ConnectionString!);
config.AbortOnConnectFail = false;
config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds;
config.ConnectRetry = 3;
if (_options.Database is not null)
{
config.DefaultDatabase = _options.Database;
}
_connection = await _connectionFactory(config).ConfigureAwait(false);
}
return _connection.GetDatabase(_options.Database ?? -1);
}
finally
{
_connectionLock.Release();
}
}
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(
_options.StreamName,
_options.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Already exists.
}
_groupInitialized = true;
}
finally
{
_groupInitLock.Release();
}
}
private NameValueEntry[] BuildEntries(
ScanQueueMessage message,
DateTimeOffset enqueuedAt,
int attempt)
{
var attributeCount = message.Attributes?.Count ?? 0;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(QueueEnvelopeFields.JobId, message.JobId);
entries[index++] = new NameValueEntry(QueueEnvelopeFields.Attempt, attempt);
entries[index++] = new NameValueEntry(QueueEnvelopeFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
entries[index++] = new NameValueEntry(
QueueEnvelopeFields.IdempotencyKey,
message.IdempotencyKey ?? message.JobId);
entries[index++] = new NameValueEntry(
QueueEnvelopeFields.Payload,
message.Payload.ToArray());
entries[index++] = new NameValueEntry(
QueueEnvelopeFields.TraceId,
message.TraceId ?? string.Empty);
if (attributeCount > 0)
{
foreach (var kvp in message.Attributes!)
{
entries[index++] = new NameValueEntry(
QueueEnvelopeFields.AttributePrefix + kvp.Key,
kvp.Value);
}
}
var result = entries.AsSpan(0, index).ToArray();
ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
return result;
}
private RedisScanQueueLease? TryMapLease(
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? jobId = null;
string? idempotency = null;
long? enqueuedAtUnix = null;
byte[]? payload = null;
string? traceId = null;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
var attempt = attemptOverride ?? 1;
foreach (var field in entry.Values)
{
var name = field.Name.ToString();
if (name.Equals(QueueEnvelopeFields.JobId, StringComparison.Ordinal))
{
jobId = field.Value.ToString();
}
else if (name.Equals(QueueEnvelopeFields.IdempotencyKey, StringComparison.Ordinal))
{
idempotency = field.Value.ToString();
}
else if (name.Equals(QueueEnvelopeFields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(field.Value.ToString(), out var unix))
{
enqueuedAtUnix = unix;
}
}
else if (name.Equals(QueueEnvelopeFields.Payload, StringComparison.Ordinal))
{
payload = (byte[]?)field.Value ?? Array.Empty<byte>();
}
else if (name.Equals(QueueEnvelopeFields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(field.Value.ToString(), out var parsedAttempt))
{
attempt = Math.Max(parsedAttempt, attempt);
}
}
else if (name.Equals(QueueEnvelopeFields.TraceId, StringComparison.Ordinal))
{
var value = field.Value.ToString();
traceId = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (name.StartsWith(QueueEnvelopeFields.AttributePrefix, StringComparison.Ordinal))
{
attributes[name[QueueEnvelopeFields.AttributePrefix.Length..]] = field.Value.ToString();
}
}
if (jobId is null || payload is null || enqueuedAtUnix is null)
{
return null;
}
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var leaseExpires = now.Add(leaseDuration);
var attributeView = attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
return new RedisScanQueueLease(
this,
entry.Id.ToString(),
jobId,
payload,
attempt,
enqueuedAt,
leaseExpires,
consumer,
idempotency,
traceId,
attributeView);
}
private TimeSpan CalculateBackoff(int attempt)
{
var configuredInitial = _options.RetryInitialBackoff > TimeSpan.Zero
? _options.RetryInitialBackoff
: _queueOptions.RetryInitialBackoff;
var initial = configuredInitial > TimeSpan.Zero
? configuredInitial
: TimeSpan.Zero;
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
if (attempt <= 1)
{
return initial;
}
var configuredMax = _queueOptions.RetryMaxBackoff > TimeSpan.Zero
? _queueOptions.RetryMaxBackoff
: initial;
var max = configuredMax <= TimeSpan.Zero
? initial
: configuredMax;
var exponent = attempt - 1;
var scale = Math.Pow(2, exponent - 1);
var scaledTicks = initial.Ticks * scale;
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
return TimeSpan.FromTicks(resultTicks);
}
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 static 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));
}
internal async ValueTask PingAsync(CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.ExecuteAsync("PING").ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Queue.Redis;
internal sealed class RedisScanQueueLease : IScanQueueLease
{
private readonly RedisScanQueue _queue;
private int _completed;
internal RedisScanQueueLease(
RedisScanQueue queue,
string messageId,
string jobId,
byte[] payload,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? idempotencyKey,
string? traceId,
IReadOnlyDictionary<string, string> attributes)
{
_queue = queue;
MessageId = messageId;
JobId = jobId;
Payload = payload;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
IdempotencyKey = idempotencyKey;
TraceId = traceId;
Attributes = attributes;
}
public string MessageId { get; }
public string JobId { get; }
public ReadOnlyMemory<byte> Payload { get; }
public int Attempt { get; }
public DateTimeOffset EnqueuedAt { get; }
public DateTimeOffset LeaseExpiresAt { get; private set; }
public string Consumer { get; }
public string? IdempotencyKey { get; }
public string? TraceId { get; }
public IReadOnlyDictionary<string, string> Attributes { 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(QueueReleaseDisposition 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;
}