Files
git.stella-ops.org/src/StellaOps.Notify.Queue/Redis/RedisNotifyDeliveryQueue.cs
master 17d861e4ab
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-10-24 09:15:37 +03:00

789 lines
28 KiB
C#

using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Queue.Redis;
internal sealed class RedisNotifyDeliveryQueue : INotifyDeliveryQueue, IAsyncDisposable
{
private const string TransportName = "redis";
private readonly NotifyDeliveryQueueOptions _options;
private readonly NotifyRedisDeliveryQueueOptions _redisOptions;
private readonly ILogger<RedisNotifyDeliveryQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly SemaphoreSlim _groupLock = new(1, 1);
private readonly ConcurrentDictionary<string, bool> _streamInitialized = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _connection;
private bool _disposed;
public RedisNotifyDeliveryQueue(
NotifyDeliveryQueueOptions options,
NotifyRedisDeliveryQueueOptions redisOptions,
ILogger<RedisNotifyDeliveryQueue> logger,
TimeProvider timeProvider,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_redisOptions = redisOptions ?? throw new ArgumentNullException(nameof(redisOptions));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_connectionFactory = connectionFactory ?? (async config =>
{
var connection = await ConnectionMultiplexer.ConnectAsync(config).ConfigureAwait(false);
return (IConnectionMultiplexer)connection;
});
if (string.IsNullOrWhiteSpace(_redisOptions.ConnectionString))
{
throw new InvalidOperationException("Redis connection string must be configured for the Notify delivery queue.");
}
}
public async ValueTask<NotifyQueueEnqueueResult> PublishAsync(
NotifyDeliveryQueueMessage message,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var attempt = 1;
var entries = BuildEntries(message, now, attempt);
var messageId = await AddToStreamAsync(
db,
_redisOptions.StreamName,
entries)
.ConfigureAwait(false);
var idempotencyKey = BuildIdempotencyKey(message.IdempotencyKey);
var stored = await db.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _options.ClaimIdleThreshold)
.ConfigureAwait(false);
if (!stored)
{
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { messageId })
.ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var duplicateId = existing.IsNullOrEmpty ? messageId : existing;
NotifyQueueMetrics.RecordDeduplicated(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Duplicate Notify delivery enqueue detected for delivery {DeliveryId}.",
message.Delivery.DeliveryId);
return new NotifyQueueEnqueueResult(duplicateId.ToString()!, true);
}
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Enqueued Notify delivery {DeliveryId} (channel {ChannelId}) into stream {Stream}.",
message.Delivery.DeliveryId,
message.ChannelId,
_redisOptions.StreamName);
return new NotifyQueueEnqueueResult(messageId.ToString()!, false);
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> LeaseAsync(
NotifyQueueLeaseRequest 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(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
request.Consumer,
StreamPosition.NewMessages,
request.BatchSize)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var now = _timeProvider.GetUtcNow();
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(entry, request.Consumer, now, request.LeaseDuration, attemptOverride: null);
if (lease is null)
{
await AckPoisonAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
public async ValueTask<IReadOnlyList<INotifyQueueLease<NotifyDeliveryQueueMessage>>> ClaimExpiredAsync(
NotifyQueueClaimOptions 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(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
options.BatchSize,
RedisValue.Null,
(long)options.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var eligible = pending
.Where(p => p.IdleTimeInMilliseconds >= options.MinIdleTime.TotalMilliseconds)
.ToArray();
if (eligible.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var messageIds = eligible
.Select(static p => (RedisValue)p.MessageId)
.ToArray();
var entries = await db.StreamClaimAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
options.ClaimantConsumer,
0,
messageIds)
.ConfigureAwait(false);
if (entries is null || entries.Length == 0)
{
return Array.Empty<INotifyQueueLease<NotifyDeliveryQueueMessage>>();
}
var now = _timeProvider.GetUtcNow();
var attemptLookup = eligible
.Where(static info => !info.MessageId.IsNullOrEmpty)
.ToDictionary(
info => info.MessageId!.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
var leases = new List<INotifyQueueLease<NotifyDeliveryQueueMessage>>(entries.Length);
foreach (var entry in entries)
{
attemptLookup.TryGetValue(entry.Id.ToString(), out var attempt);
var lease = TryMapLease(entry, options.ClaimantConsumer, now, _options.DefaultLeaseDuration, attempt == 0 ? null : attempt);
if (lease is null)
{
await AckPoisonAsync(db, 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().ConfigureAwait(false);
_connection.Dispose();
}
_connectionLock.Dispose();
_groupLock.Dispose();
GC.SuppressFinalize(this);
}
internal async Task AcknowledgeAsync(
RedisNotifyDeliveryLease lease,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
_logger.LogDebug(
"Acknowledged Notify delivery {DeliveryId} (message {MessageId}).",
lease.Message.Delivery.DeliveryId,
lease.MessageId);
}
internal async Task RenewLeaseAsync(
RedisNotifyDeliveryLease lease,
TimeSpan leaseDuration,
CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamClaimAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
lease.Consumer,
0,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(leaseDuration);
lease.RefreshLease(expires);
_logger.LogDebug(
"Renewed Notify delivery lease {DeliveryId} until {Expires:u}.",
lease.Message.Delivery.DeliveryId,
expires);
}
internal async Task ReleaseAsync(
RedisNotifyDeliveryLease lease,
NotifyQueueReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == NotifyQueueReleaseDisposition.Retry
&& lease.Attempt >= _options.MaxDeliveryAttempts)
{
_logger.LogWarning(
"Notify delivery {DeliveryId} reached max delivery attempts ({Attempts}); moving to dead-letter stream.",
lease.Message.Delivery.DeliveryId,
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(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
if (disposition == NotifyQueueReleaseDisposition.Retry)
{
NotifyQueueMetrics.RecordRetry(TransportName, _redisOptions.StreamName);
var delay = CalculateBackoff(lease.Attempt);
if (delay > TimeSpan.Zero)
{
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt + 1);
await AddToStreamAsync(
db,
_redisOptions.StreamName,
entries)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordEnqueued(TransportName, _redisOptions.StreamName);
_logger.LogInformation(
"Retrying Notify delivery {DeliveryId} (attempt {Attempt}).",
lease.Message.Delivery.DeliveryId,
lease.Attempt + 1);
}
else
{
NotifyQueueMetrics.RecordAck(TransportName, _redisOptions.StreamName);
_logger.LogInformation(
"Abandoned Notify delivery {DeliveryId} after {Attempt} attempt(s).",
lease.Message.Delivery.DeliveryId,
lease.Attempt);
}
}
internal async Task DeadLetterAsync(
RedisNotifyDeliveryLease lease,
string reason,
CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await db.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { lease.MessageId })
.ConfigureAwait(false);
await EnsureDeadLetterStreamAsync(db, cancellationToken).ConfigureAwait(false);
var entries = BuildDeadLetterEntries(lease, reason);
await AddToStreamAsync(
db,
_redisOptions.DeadLetterStreamName,
entries)
.ConfigureAwait(false);
NotifyQueueMetrics.RecordDeadLetter(TransportName, _redisOptions.DeadLetterStreamName);
_logger.LogError(
"Dead-lettered Notify delivery {DeliveryId} (attempt {Attempt}): {Reason}",
lease.Message.Delivery.DeliveryId,
lease.Attempt,
reason);
}
internal async ValueTask PingAsync(CancellationToken cancellationToken)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
_ = await db.PingAsync().ConfigureAwait(false);
}
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is { IsConnected: true })
{
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
var configuration = ConfigurationOptions.Parse(_redisOptions.ConnectionString!);
configuration.AbortOnConnectFail = false;
if (_redisOptions.Database.HasValue)
{
configuration.DefaultDatabase = _redisOptions.Database.Value;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_redisOptions.InitializationTimeout);
_connection = await _connectionFactory(configuration).WaitAsync(timeoutCts.Token).ConfigureAwait(false);
return _connection.GetDatabase(_redisOptions.Database ?? -1);
}
finally
{
_connectionLock.Release();
}
}
private async Task EnsureConsumerGroupAsync(
IDatabase database,
CancellationToken cancellationToken)
{
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
{
return;
}
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_streamInitialized.ContainsKey(_redisOptions.StreamName))
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// group already exists
}
_streamInitialized[_redisOptions.StreamName] = true;
}
finally
{
_groupLock.Release();
}
}
private async Task EnsureDeadLetterStreamAsync(
IDatabase database,
CancellationToken cancellationToken)
{
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
{
return;
}
await _groupLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_streamInitialized.ContainsKey(_redisOptions.DeadLetterStreamName))
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_redisOptions.DeadLetterStreamName,
_redisOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// ignore
}
_streamInitialized[_redisOptions.DeadLetterStreamName] = true;
}
finally
{
_groupLock.Release();
}
}
private NameValueEntry[] BuildEntries(
NotifyDeliveryQueueMessage message,
DateTimeOffset enqueuedAt,
int attempt)
{
var json = NotifyCanonicalJsonSerializer.Serialize(message.Delivery);
var attributeCount = message.Attributes.Count;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(8 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, message.Delivery.DeliveryId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, message.ChannelId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, message.ChannelType.ToString());
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, message.Delivery.TenantId);
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, attempt);
entries[index++] = new NameValueEntry(NotifyQueueFields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, message.IdempotencyKey);
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, message.TraceId ?? string.Empty);
entries[index++] = new NameValueEntry(NotifyQueueFields.PartitionKey, message.PartitionKey);
if (attributeCount > 0)
{
foreach (var kvp in message.Attributes)
{
entries[index++] = new NameValueEntry(
NotifyQueueFields.AttributePrefix + kvp.Key,
kvp.Value);
}
}
return entries.AsSpan(0, index).ToArray();
}
private NameValueEntry[] BuildDeadLetterEntries(RedisNotifyDeliveryLease lease, string reason)
{
var json = NotifyCanonicalJsonSerializer.Serialize(lease.Message.Delivery);
var attributes = lease.Message.Attributes;
var attributeCount = attributes.Count;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(9 + attributeCount);
var index = 0;
entries[index++] = new NameValueEntry(NotifyQueueFields.Payload, json);
entries[index++] = new NameValueEntry(NotifyQueueFields.DeliveryId, lease.Message.Delivery.DeliveryId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelId, lease.Message.ChannelId);
entries[index++] = new NameValueEntry(NotifyQueueFields.ChannelType, lease.Message.ChannelType.ToString());
entries[index++] = new NameValueEntry(NotifyQueueFields.Tenant, lease.Message.Delivery.TenantId);
entries[index++] = new NameValueEntry(NotifyQueueFields.Attempt, lease.Attempt);
entries[index++] = new NameValueEntry(NotifyQueueFields.IdempotencyKey, lease.Message.IdempotencyKey);
entries[index++] = new NameValueEntry("deadletter-reason", reason);
entries[index++] = new NameValueEntry(NotifyQueueFields.TraceId, lease.Message.TraceId ?? string.Empty);
foreach (var kvp in attributes)
{
entries[index++] = new NameValueEntry(
NotifyQueueFields.AttributePrefix + kvp.Key,
kvp.Value);
}
return entries.AsSpan(0, index).ToArray();
}
private RedisNotifyDeliveryLease? 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? deliveryId = null;
string? channelId = null;
string? channelTypeRaw = null;
string? traceId = null;
string? idempotency = null;
string? partitionKey = null;
long? enqueuedAtUnix = null;
var attempt = attemptOverride ?? 1;
var attributes = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var value in entry.Values)
{
var name = value.Name.ToString();
var data = value.Value;
if (name.Equals(NotifyQueueFields.Payload, StringComparison.Ordinal))
{
payload = data.ToString();
}
else if (name.Equals(NotifyQueueFields.DeliveryId, StringComparison.Ordinal))
{
deliveryId = data.ToString();
}
else if (name.Equals(NotifyQueueFields.ChannelId, StringComparison.Ordinal))
{
channelId = data.ToString();
}
else if (name.Equals(NotifyQueueFields.ChannelType, StringComparison.Ordinal))
{
channelTypeRaw = data.ToString();
}
else if (name.Equals(NotifyQueueFields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
{
attempt = Math.Max(parsed, attempt);
}
}
else if (name.Equals(NotifyQueueFields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(data.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix))
{
enqueuedAtUnix = unix;
}
}
else if (name.Equals(NotifyQueueFields.IdempotencyKey, StringComparison.Ordinal))
{
idempotency = data.ToString();
}
else if (name.Equals(NotifyQueueFields.TraceId, StringComparison.Ordinal))
{
var text = data.ToString();
traceId = string.IsNullOrWhiteSpace(text) ? null : text;
}
else if (name.Equals(NotifyQueueFields.PartitionKey, StringComparison.Ordinal))
{
partitionKey = data.ToString();
}
else if (name.StartsWith(NotifyQueueFields.AttributePrefix, StringComparison.Ordinal))
{
attributes[name[NotifyQueueFields.AttributePrefix.Length..]] = data.ToString();
}
}
if (payload is null || deliveryId is null || channelId is null || channelTypeRaw is null)
{
return null;
}
NotifyDelivery delivery;
try
{
delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(payload);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to deserialize Notify delivery payload for entry {EntryId}.",
entry.Id.ToString());
return null;
}
if (!Enum.TryParse<NotifyChannelType>(channelTypeRaw, ignoreCase: true, out var channelType))
{
_logger.LogWarning(
"Unknown channel type '{ChannelType}' for delivery {DeliveryId}; acknowledging as poison.",
channelTypeRaw,
deliveryId);
return null;
}
var attributeView = attributes.Count == 0
? EmptyReadOnlyDictionary<string, string>.Instance
: new ReadOnlyDictionary<string, string>(attributes);
var enqueuedAt = enqueuedAtUnix is null
? now
: DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var message = new NotifyDeliveryQueueMessage(
delivery,
channelId,
channelType,
_redisOptions.StreamName,
traceId,
attributeView);
var leaseExpires = now.Add(leaseDuration);
return new RedisNotifyDeliveryLease(
this,
entry.Id.ToString(),
message,
attempt,
enqueuedAt,
leaseExpires,
consumer,
idempotency,
partitionKey ?? channelId);
}
private async Task AckPoisonAsync(IDatabase database, RedisValue messageId)
{
await database.StreamAcknowledgeAsync(
_redisOptions.StreamName,
_redisOptions.ConsumerGroup,
new RedisValue[] { messageId })
.ConfigureAwait(false);
await database.StreamDeleteAsync(
_redisOptions.StreamName,
new RedisValue[] { messageId })
.ConfigureAwait(false);
}
private static async Task<RedisValue> AddToStreamAsync(
IDatabase database,
string stream,
IReadOnlyList<NameValueEntry> entries)
{
return await database.StreamAddAsync(
stream,
entries.ToArray())
.ConfigureAwait(false);
}
private string BuildIdempotencyKey(string token)
=> string.Concat(_redisOptions.IdempotencyKeyPrefix, token);
private TimeSpan CalculateBackoff(int attempt)
{
var initial = _options.RetryInitialBackoff > TimeSpan.Zero
? _options.RetryInitialBackoff
: TimeSpan.FromSeconds(1);
if (initial <= TimeSpan.Zero)
{
return TimeSpan.Zero;
}
if (attempt <= 1)
{
return initial;
}
var max = _options.RetryMaxBackoff > TimeSpan.Zero
? _options.RetryMaxBackoff
: initial;
var exponent = attempt - 1;
var scaledTicks = initial.Ticks * Math.Pow(2, exponent - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
var resultTicks = Math.Max(initial.Ticks, (long)cappedTicks);
return TimeSpan.FromTicks(resultTicks);
}
}