Files

207 lines
7.5 KiB
C#

using System;
namespace LaunchDarkly.EventSource
{
internal sealed class ExponentialBackoffWithDecorrelation
{
private readonly object _lock = new object();
private readonly Random _randomSource;
private TimeSpan _minimumDelay;
private TimeSpan _maximumDelay;
private int _backoffN;
private TimeSpan? _serverDirectedMinDelay;
public ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay)
: this(minimumDelay, maximumDelay, new Random())
{
}
/// <summary>
/// Constructs an instance with a caller-supplied <see cref="Random"/>, so that tests can
/// make jitter deterministic.
/// </summary>
internal ExponentialBackoffWithDecorrelation(TimeSpan minimumDelay, TimeSpan maximumDelay,
Random randomSource)
{
_minimumDelay = NonNegative(minimumDelay);
_maximumDelay = NonNegative(maximumDelay);
_randomSource = randomSource ?? new Random();
}
/// <summary>
/// Gets the next backoff duration, advancing n.
/// </summary>
public TimeSpan GetNextBackOff()
{
lock (_lock)
{
long unjittered = GetUnjitteredMillisecondsForN(_backoffN);
// Random.Next takes an int bound. 2^31 milliseconds is far longer than any
// reconnect delay we would use, so saturating here cannot affect a realistic
// delay.
int jitterBound = (int)Math.Min(unjittered, int.MaxValue);
long delayMillis = unjittered / 2
+ (jitterBound > 0 ? _randomSource.Next(jitterBound) / 2 : 0);
_backoffN++;
return TimeSpan.FromMilliseconds(delayMillis);
}
}
/// <summary>
/// Returns the delay in whole milliseconds for a given backoff <c>n</c>, before jitter is
/// applied: <c>minimumDelay * 2^n</c>, limited to the maximum.
/// </summary>
/// <remarks>
/// <para>
/// The caller must hold the lock. This reads the bounds without taking it because it is the
/// inner core of <see cref="GetNextBackOff"/>, which already holds it; taking it here would
/// only be a re-entrant acquisition on the hot path. Anything needing a synchronized read of
/// the bounds on its own should use the accessors below, which take the lock themselves.
/// </para>
/// <para>
/// <c>n</c> is compared against the ceiling rather than used to compute a value that is
/// then clamped, so no intermediate ever exceeds the maximum. That matters because
/// <c>n</c> is unbounded: a long outage keeps incrementing it, and computing
/// <c>minimumDelay * 2^n</c> directly would overflow.
/// </para>
/// </remarks>
internal long GetUnjitteredMillisecondsForN(int n)
{
long min = (_serverDirectedMinDelay ?? _minimumDelay).Ticks
/ TimeSpan.TicksPerMillisecond;
long max = _maximumDelay.Ticks / TimeSpan.TicksPerMillisecond;
if (min <= 0 || max <= 0)
{
return 0;
}
if (n <= 0)
{
return min > max ? max : min;
}
// Shifting the ceiling down cannot overflow, whereas shifting the minimum up can.
// The n >= 63 test is required rather than defensive: C# masks a 64-bit shift count
// to its low 6 bits, so `max >> 64` would silently mean `max >> 0`.
if (n >= 63 || min > (max >> n))
{
return max;
}
return min << n;
}
/// <summary>
/// Applies a server-directed reconnection time received via the SSE <c>retry:</c> field,
/// replacing the minimum delay used to compute subsequent backoffs.
/// </summary>
/// <remarks>
/// <para>
/// The override takes precedence over both the configured minimum and any minimum
/// installed by <see cref="SetBounds"/>. It is sticky: it survives bounds changes and
/// <see cref="ResetBackoffN"/>, and is replaced only by a later server-directed value.
/// </para>
/// <para>
/// The maximum is never affected, so the ceiling that bounds reconnection keeps applying.
/// </para>
/// <para>
/// n resets, so the next attempt uses the directed value itself rather than continuing to
/// double from wherever the progression had reached.
/// </para>
/// <para>
/// Negative values become zero. No upper bound is applied here; a caller taking a value
/// from an untrusted source is responsible for capping it first.
/// </para>
/// </remarks>
/// <param name="minDelay">the server-directed reconnection time</param>
public void SetServerDirectedMinDelay(TimeSpan minDelay)
{
var value = NonNegative(minDelay);
lock (_lock)
{
_serverDirectedMinDelay = value;
_backoffN = 0;
}
}
/// <summary>
/// Replaces the delay bounds.
/// </summary>
/// <remarks>
/// If the new bounds differ from the current ones, n resets so that the
/// next delay starts from the new minimum. If they are the same, this does nothing, which
/// makes repeated calls with identical values idempotent -- a caller that reapplies the
/// same bounds on every failure will not pin the delay at the minimum.
/// </remarks>
/// <param name="minimumDelay">the new minimum delay; negative values become zero</param>
/// <param name="maximumDelay">the new maximum delay; negative values become zero</param>
public void SetBounds(TimeSpan minimumDelay, TimeSpan maximumDelay)
{
var min = NonNegative(minimumDelay);
var max = NonNegative(maximumDelay);
lock (_lock)
{
if (min == _minimumDelay && max == _maximumDelay)
{
return;
}
_minimumDelay = min;
_maximumDelay = max;
_backoffN = 0;
}
}
/// <summary>
/// Resets n so the next delay starts from the current minimum, without
/// changing the bounds.
/// </summary>
public void ResetBackoffN()
{
lock (_lock)
{
_backoffN = 0;
}
}
// Accessors for tests.
internal int GetBackoffN()
{
lock (_lock)
{
return _backoffN;
}
}
internal TimeSpan? GetServerDirectedMinDelay()
{
lock (_lock)
{
return _serverDirectedMinDelay;
}
}
internal TimeSpan GetMinimumDelay()
{
lock (_lock)
{
return _minimumDelay;
}
}
internal TimeSpan GetMaximumDelay()
{
lock (_lock)
{
return _maximumDelay;
}
}
// Matches the clamping behavior of ConfigurationBuilder's retry-delay setters: a negative
// duration becomes zero rather than throwing.
private static TimeSpan NonNegative(TimeSpan t) => t < TimeSpan.Zero ? TimeSpan.Zero : t;
}
}