Restructure solution layout by module
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user