Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ internal sealed class AutoRecoveryService
private readonly TimeSpan _delay;
private static readonly TimeSpan _minDelay = TimeSpan.FromMilliseconds(10);
private CancellationTokenSource? _cts;
private long _barrierTicks = 0;

private const long NoBarrierAnchor = long.MinValue;
private long _barrierTimestamp = NoBarrierAnchor;

public AutoRecoveryService(FusionCache cache, FusionCacheOptions options, ILogger<FusionCache>? logger)
{
Expand Down Expand Up @@ -239,8 +241,8 @@ internal bool TryUpdateBarrier(string operationId)
if (_queue.IsEmpty)
return false;

var newBarrier = DateTimeOffset.UtcNow.Ticks + _delay.Ticks;
var oldBarrier = Interlocked.Exchange(ref _barrierTicks, newBarrier);
var newBarrier = Stopwatch.GetTimestamp();
var oldBarrier = Interlocked.Exchange(ref _barrierTimestamp, newBarrier);

if (_logger?.IsEnabled(LogLevel.Trace) ?? false)
_logger.Log(LogLevel.Trace, "FUSION [N={CacheName} I={CacheInstanceId}] (O={CacheOperationId}): auto-recovery barrier set from {OldAutoRecoveryBarrier} to {NewAutoRecoveryBarrier}", _cache.CacheName, _cache.InstanceId, operationId, oldBarrier, newBarrier);
Expand All @@ -253,12 +255,12 @@ internal bool TryUpdateBarrier(string operationId)

internal bool IsBehindBarrier()
{
var barrierTicks = Interlocked.Read(ref _barrierTicks);
var anchor = Interlocked.Read(ref _barrierTimestamp);

if (DateTimeOffset.UtcNow.Ticks < barrierTicks)
return true;
if (anchor == NoBarrierAnchor)
return false;

return false;
return StopwatchPolyfill.GetElapsedTime(anchor) < _delay;
}

internal async ValueTask<bool> TryProcessQueueAsync(string operationId, CancellationToken token)
Expand Down Expand Up @@ -599,24 +601,27 @@ internal async Task BackgroundJobAsync()
{
var operationId = FusionCacheInternalUtils.MaybeGenerateOperationId(_logger);
var delay = _delay;
var nowTicks = DateTimeOffset.UtcNow.Ticks;
var barrierTicks = Interlocked.Read(ref _barrierTicks);
if (nowTicks < barrierTicks)
var anchor = Interlocked.Read(ref _barrierTimestamp);
if (anchor != NoBarrierAnchor)
{
// SET THE NEW DELAY TO REACH THE BARRIER (+ A MICROSCOPIC EXTRA)
var oldDelay = delay;
var newDelayTicks = barrierTicks - nowTicks + 1_000;
delay = TimeSpan.FromTicks(newDelayTicks);

// CHECK IF THE NEW DELAY IS BELOW A SAFETY LIMIT
if (delay < _minDelay)
var elapsed = StopwatchPolyfill.GetElapsedTime(anchor);
if (elapsed < _delay)
{
delay = _minDelay;
newDelayTicks = delay.Ticks;
}
// SET THE NEW DELAY TO REACH THE BARRIER (+ A MICROSCOPIC EXTRA)
var oldDelay = delay;
var newDelayTicks = (_delay - elapsed).Ticks + 1_000;
delay = TimeSpan.FromTicks(newDelayTicks);

if (_logger?.IsEnabled(LogLevel.Trace) ?? false)
_logger.Log(LogLevel.Trace, "FUSION [N={CacheName} I={CacheInstanceId}] (O={CacheOperationId}): instead of the standard auto-recovery delay of {AutoRecoveryNormalDelay} the new delay is {AutoRecoveryNewDelay} ({AutoRecoveryNewDelayMs} ms, {AutoRecoveryNewDelayTicks} ticks)", _cache.CacheName, _cache.InstanceId, operationId, oldDelay, delay, delay.TotalMilliseconds, newDelayTicks);
// CHECK IF THE NEW DELAY IS BELOW A SAFETY LIMIT
if (delay < _minDelay)
{
delay = _minDelay;
newDelayTicks = delay.Ticks;
}

if (_logger?.IsEnabled(LogLevel.Trace) ?? false)
_logger.Log(LogLevel.Trace, "FUSION [N={CacheName} I={CacheInstanceId}] (O={CacheOperationId}): instead of the standard auto-recovery delay of {AutoRecoveryNormalDelay} the new delay is {AutoRecoveryNewDelay} ({AutoRecoveryNewDelayMs} ms, {AutoRecoveryNewDelayTicks} ticks)", _cache.CacheName, _cache.InstanceId, operationId, oldDelay, delay, delay.TotalMilliseconds, newDelayTicks);
}
}

if (_queue.IsEmpty == false)
Expand Down
23 changes: 12 additions & 11 deletions src/ZiggyCreatures.FusionCache/Internals/SimpleCircuitBreaker.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ZiggyCreatures.Caching.Fusion.Internals;
using System.Diagnostics;

namespace ZiggyCreatures.Caching.Fusion.Internals;

/// <summary>
/// A simple, reusable circuit-breaker.
Expand All @@ -8,9 +10,10 @@ internal sealed class SimpleCircuitBreaker
private const int CircuitStateClosed = 0;
private const int CircuitStateOpen = 1;

private const long NoAnchor = long.MinValue;

private int _circuitState;
private long _gatewayTicks;
private readonly long _breakDurationTicks;
private long _gatewayTimestamp = NoAnchor;

/// <summary>
/// Creates a new <see cref="SimpleCircuitBreaker"/> instance.
Expand All @@ -19,8 +22,6 @@ internal sealed class SimpleCircuitBreaker
public SimpleCircuitBreaker(TimeSpan breakDuration)
{
BreakDuration = breakDuration;
_breakDurationTicks = BreakDuration.Ticks;
_gatewayTicks = DateTimeOffset.MinValue.Ticks;
}

/// <summary>
Expand All @@ -36,13 +37,13 @@ public SimpleCircuitBreaker(TimeSpan breakDuration)
public bool TryOpen(out bool isStateChanged)
{
// NO CIRCUIT-BREAKER DURATION
if (_breakDurationTicks == 0)
if (BreakDuration == TimeSpan.Zero)
{
isStateChanged = false;
return false;
}

Interlocked.Exchange(ref _gatewayTicks, DateTimeOffset.UtcNow.Ticks + _breakDurationTicks);
Interlocked.Exchange(ref _gatewayTimestamp, Stopwatch.GetTimestamp());

// DETECT CIRCUIT STATE CHANGE
var oldCircuitState = Interlocked.Exchange(ref _circuitState, CircuitStateOpen);
Expand All @@ -57,7 +58,7 @@ public bool TryOpen(out bool isStateChanged)
/// <param name="isStateChanged">Indicates if the circuit has been closed with this operation.</param>
public void Close(out bool isStateChanged)
{
Interlocked.Exchange(ref _gatewayTicks, DateTimeOffset.MinValue.Ticks);
Interlocked.Exchange(ref _gatewayTimestamp, NoAnchor);

// DETECT CIRCUIT STATE CHANGE
var oldCircuitState = Interlocked.Exchange(ref _circuitState, CircuitStateClosed);
Expand All @@ -75,13 +76,13 @@ public bool IsClosed(out bool isStateChanged)
isStateChanged = false;

// NO CIRCUIT-BREAKER DURATION
if (_breakDurationTicks == 0)
if (BreakDuration == TimeSpan.Zero)
return true;

long gatewayTicksLocal = Interlocked.Read(ref _gatewayTicks);
var anchor = Interlocked.Read(ref _gatewayTimestamp);

// NOT ENOUGH TIME IS PASSED
if (DateTimeOffset.UtcNow.Ticks < gatewayTicksLocal)
if (anchor != NoAnchor && StopwatchPolyfill.GetElapsedTime(anchor) < BreakDuration)
return false;

if (_circuitState == CircuitStateOpen)
Expand Down
21 changes: 21 additions & 0 deletions src/ZiggyCreatures.FusionCache/Internals/StopwatchPolyfill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;

namespace ZiggyCreatures.Caching.Fusion.Internals;

internal static class StopwatchPolyfill
{
#if !NET7_0_OR_GREATER
private static readonly double s_tickFrequency = (double)TimeSpan.TicksPerSecond / Stopwatch.Frequency;
#endif

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TimeSpan GetElapsedTime(long startingTimestamp)
{
#if NET7_0_OR_GREATER
return Stopwatch.GetElapsedTime(startingTimestamp);
#else
return new TimeSpan((long)((Stopwatch.GetTimestamp() - startingTimestamp) * s_tickFrequency));
#endif
}
}
139 changes: 139 additions & 0 deletions tests/ZiggyCreatures.FusionCache.Tests/SimpleCircuitBreakerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using FusionCacheTests.Stuff;
using Xunit;
using ZiggyCreatures.Caching.Fusion.Internals;

namespace FusionCacheTests;

public class SimpleCircuitBreakerTests
{
[Fact]
public void ZeroBreakDuration_TryOpen_IsNoOp()
{
var cb = new SimpleCircuitBreaker(TimeSpan.Zero);

var opened = cb.TryOpen(out var openChanged);
var closed = cb.IsClosed(out var closeChanged);

Assert.False(opened);
Assert.False(openChanged);
Assert.True(closed);
Assert.False(closeChanged);
}

[Fact]
public void TryOpen_FirstTime_ReportsStateChange()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(1));

var opened = cb.TryOpen(out var stateChanged);

Assert.True(opened);
Assert.True(stateChanged);
}

[Fact]
public void TryOpen_WhileAlreadyOpen_ReportsNoStateChange()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(1));
cb.TryOpen(out _);

var opened = cb.TryOpen(out var stateChanged);

Assert.True(opened);
Assert.False(stateChanged);
}

[Fact]
public void IsClosed_BeforeFirstOpen_ReturnsTrue()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(1));

var closed = cb.IsClosed(out var stateChanged);

Assert.True(closed);
Assert.False(stateChanged);
}

[Fact]
public void IsClosed_WithinBreakDuration_ReturnsFalse()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(10));
cb.TryOpen(out _);

var closed = cb.IsClosed(out var stateChanged);

Assert.False(closed);
Assert.False(stateChanged);
}

[Fact]
public async Task IsClosed_AfterBreakDurationElapses_ReturnsTrueAndReportsStateChange()
{
var breakDuration = TimeSpan.FromSeconds(1);
var cb = new SimpleCircuitBreaker(breakDuration);
cb.TryOpen(out _);

await Task.Delay(breakDuration.PlusASecond(), TestContext.Current.CancellationToken);

var closed = cb.IsClosed(out var stateChanged);

Assert.True(closed);
Assert.True(stateChanged);
}

[Fact]
public async Task IsClosed_AfterBreakDurationElapses_SubsequentCallReportsNoStateChange()
{
var breakDuration = TimeSpan.FromSeconds(1);
var cb = new SimpleCircuitBreaker(breakDuration);
cb.TryOpen(out _);
await Task.Delay(breakDuration.PlusASecond(), TestContext.Current.CancellationToken);
cb.IsClosed(out _);

var closed = cb.IsClosed(out var stateChanged);

Assert.True(closed);
Assert.False(stateChanged);
}

[Fact]
public void Close_AfterTryOpen_ReportsStateChange()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(1));
cb.TryOpen(out _);

cb.Close(out var stateChanged);

Assert.True(stateChanged);
Assert.True(cb.IsClosed(out _));
}

[Fact]
public void Close_WhenAlreadyClosed_ReportsNoStateChange()
{
var cb = new SimpleCircuitBreaker(TimeSpan.FromSeconds(1));

cb.Close(out var stateChanged);

Assert.False(stateChanged);
}

[Fact]
public async Task TryOpen_ResetsBreakWindow()
{
var breakDuration = TimeSpan.FromSeconds(2);
var cb = new SimpleCircuitBreaker(breakDuration);
cb.TryOpen(out _);

await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
// re-open: the window restarts from "now"
cb.TryOpen(out _);

// ~1s into the new 2s window — the original window would already be expired without the reset
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
Assert.False(cb.IsClosed(out _));

await Task.Delay(breakDuration.PlusASecond(), TestContext.Current.CancellationToken);
Assert.True(cb.IsClosed(out _));
}
}