File: Internal\RetryBudget.cs
Web Access
Project: src\src\Libraries\Microsoft.Extensions.Http.Resilience\Microsoft.Extensions.Http.Resilience.csproj (Microsoft.Extensions.Http.Resilience)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Threading;
 
namespace Microsoft.Extensions.Http.Resilience.Internal;
 
/// <summary>
/// Tracks requests and retries over a sliding window and decides whether a retry is allowed.
/// </summary>
/// <remarks>
/// The window is split into <see cref="BucketCount"/> buckets. Each bucket has a request counter and a retry counter,
/// stored next to each other so that a decision reads them in a single pass. Each counter packs its epoch (upper 32 bits)
/// and its count (lower 32 bits) into a single <see cref="long"/>. While the epoch is current, the counter is updated with
/// a single atomic increment; a compare-and-swap is only needed to reset a counter for a new epoch.
/// A counter whose epoch is older than the window is lazily reset on the next write and ignored on reads. A delayed writer
/// never resets a counter that was already reused for a newer epoch; its increment lands in that newer epoch instead.
/// The check and the increment in <see cref="TryAcquireRetry"/> are not atomic, so concurrent retry decisions
/// can slightly overshoot the configured ratio. This is acceptable for a load-shedding heuristic.
/// </remarks>
internal sealed class RetryBudget
{
    internal const int BucketCount = 10;
 
    private const int EpochShift = 32;
    private const long CountMask = 0xFFFF_FFFF;
 
    // Below this count, a plain atomic increment can't carry into the epoch bits, even with many concurrent writers.
    private const long FastPathCountLimit = CountMask >> 1;
 
    internal const int RequestsOffset = 0;
    internal const int RetriesOffset = 1;
    private const int CountersPerBucket = 2;
 
    private readonly long[] _counters = new long[BucketCount * CountersPerBucket];
    private readonly TimeProvider _timeProvider;
    private readonly long _startTimestamp;
    private readonly long _bucketTicks;
    private readonly double _retryRatio;
    private readonly int _minimumThroughput;
 
    public RetryBudget(double retryRatio, TimeSpan samplingDuration, int minimumThroughput, TimeProvider timeProvider)
    {
        _timeProvider = timeProvider;
        _retryRatio = retryRatio;
        _minimumThroughput = minimumThroughput;
        _startTimestamp = timeProvider.GetTimestamp();
        _bucketTicks = Math.Max(1, (long)(samplingDuration.TotalSeconds * timeProvider.TimestampFrequency / BucketCount));
    }
 
    public void RecordRequest() => Increment(GetCurrentEpoch(), RequestsOffset);
 
    public bool TryAcquireRetry()
    {
        // Checked before the minimum throughput so that a zero ratio reliably suppresses every retry,
        // including when traffic to a failing dependency has collapsed below the minimum.
        if (_retryRatio <= 0)
        {
            return false;
        }
 
        var epoch = GetCurrentEpoch();
        Sum(epoch, out var requests, out var retries);
 
        // The allowance is floored at one retry so that a small ratio doesn't turn the budget into a total block
        // for traffic just above the minimum throughput, where the ratio multiplied by the requests is below 1.
        if (requests >= _minimumThroughput && retries >= Math.Max(1, (long)(_retryRatio * requests)))
        {
            return false;
        }
 
        Increment(epoch, RetriesOffset);
        return true;
    }
 
    private static uint GetEpoch(long value) => (uint)((ulong)value >> EpochShift);
 
    private static long GetCount(long value) => value & CountMask;
 
    // Unsigned subtraction handles epoch wrap-around; future epochs (from a racing writer) wrap to large values and are skipped.
    private static long GetLiveCount(long value, uint epoch) => unchecked(epoch - GetEpoch(value)) < BucketCount ? GetCount(value) : 0;
 
    internal void Increment(uint epoch, int offset)
    {
        ref long counter = ref _counters[((int)(epoch % BucketCount) * CountersPerBucket) + offset];
 
        while (true)
        {
            var current = Volatile.Read(ref counter);
            var currentEpoch = GetEpoch(current);
            long count = 1;
 
            if (currentEpoch != epoch && IsNewer(currentEpoch, epoch))
            {
                // This writer was delayed long enough for another writer to reuse the bucket for a newer epoch.
                // Resetting it would discard that newer data, so the increment lands in the newer epoch instead.
                epoch = currentEpoch;
            }
 
            if (currentEpoch == epoch)
            {
                count = GetCount(current);
 
                if (count < FastPathCountLimit)
                {
                    // If a racing writer resets the counter for a newer epoch in between, this increment lands in that
                    // newer epoch, which is at least as recent, so the sample is still counted.
                    _ = Interlocked.Increment(ref counter);
                    return;
                }
 
                // Saturate instead of overflowing into the epoch bits.
                if (count == CountMask)
                {
                    return;
                }
 
                count++;
            }
 
            var updated = ((long)epoch << EpochShift) | count;
 
            if (Interlocked.CompareExchange(ref counter, updated, current) == current)
            {
                return;
            }
        }
    }
 
    // Distances are measured back from the current epoch with unsigned subtraction, so this handles wrap-around and
    // treats a counter that was never written (epoch 0) as older, however much time has passed.
    private bool IsNewer(uint epoch, uint than)
    {
        var now = GetCurrentEpoch();
        return unchecked(now - epoch) < unchecked(now - than);
    }
 
    private void Sum(uint epoch, out long requests, out long retries)
    {
        long requestSum = 0;
        long retrySum = 0;
 
        for (int i = 0; i < _counters.Length; i += CountersPerBucket)
        {
            requestSum += GetLiveCount(Volatile.Read(ref _counters[i + RequestsOffset]), epoch);
            retrySum += GetLiveCount(Volatile.Read(ref _counters[i + RetriesOffset]), epoch);
        }
 
        requests = requestSum;
        retries = retrySum;
    }
 
    private uint GetCurrentEpoch() => unchecked((uint)((_timeProvider.GetTimestamp() - _startTimestamp) / _bucketTicks));
}