Skip to content

Commit abdb423

Browse files
Improve retry window.
1 parent 9f14683 commit abdb423

File tree

4 files changed

+236
-109
lines changed

4 files changed

+236
-109
lines changed

events/Squidex.Events.Tests/MongoEventStoreDocumentDbTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
// All rights reserved. Licensed under the MIT license.
66
// ==========================================================================
77

8+
using System.Security.Cryptography.X509Certificates;
89
using Microsoft.Extensions.Configuration;
910
using Microsoft.Extensions.DependencyInjection;
1011
using MongoDB.Bson;
1112
using MongoDB.Driver;
1213
using Squidex.Hosting;
13-
using System.Security.Cryptography.X509Certificates;
1414
using TestHelpers;
1515

1616
#pragma warning disable MA0048 // File name must match type name
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
using Squidex.Events.Utils;
9+
10+
namespace Squidex.Events;
11+
12+
public class RetryWindowTests
13+
{
14+
private readonly TimeProvider clock = A.Fake<TimeProvider>();
15+
private DateTimeOffset now = DateTimeOffset.UtcNow;
16+
17+
public RetryWindowTests()
18+
{
19+
A.CallTo(() => clock.GetUtcNow())
20+
.ReturnsLazily(() => now);
21+
}
22+
23+
[Theory]
24+
[InlineData(0)]
25+
[InlineData(-1)]
26+
public void Should_handle_non_positive_window_size_as_rate_limiter(int windowSize)
27+
{
28+
var sut = new RetryWindow(TimeSpan.FromSeconds(10), windowSize, clock);
29+
30+
Assert.True(sut.CanRetryAfterFailure());
31+
now = now.AddSeconds(1);
32+
Assert.False(sut.CanRetryAfterFailure());
33+
now = now.AddSeconds(11);
34+
Assert.True(sut.CanRetryAfterFailure());
35+
}
36+
37+
[Fact]
38+
public void Should_allow_retries_within_window_size()
39+
{
40+
var sut = new RetryWindow(TimeSpan.FromMinutes(1), 3, clock);
41+
42+
Assert.True(sut.CanRetryAfterFailure());
43+
Assert.True(sut.CanRetryAfterFailure());
44+
Assert.True(sut.CanRetryAfterFailure());
45+
46+
Assert.False(sut.CanRetryAfterFailure());
47+
}
48+
49+
[Fact]
50+
public void Should_allow_retry_after_window_duration_expires()
51+
{
52+
var sut = new RetryWindow(TimeSpan.FromMinutes(1), 3, clock);
53+
54+
Assert.True(sut.CanRetryAfterFailure());
55+
Assert.True(sut.CanRetryAfterFailure());
56+
Assert.True(sut.CanRetryAfterFailure());
57+
Assert.False(sut.CanRetryAfterFailure());
58+
59+
now = now.AddMinutes(2);
60+
61+
Assert.True(sut.CanRetryAfterFailure());
62+
}
63+
64+
[Fact]
65+
public void Should_slide_window_as_time_passes()
66+
{
67+
var sut = new RetryWindow(TimeSpan.FromSeconds(30), 2, clock);
68+
69+
Assert.True(sut.CanRetryAfterFailure());
70+
71+
now = now.AddSeconds(5);
72+
Assert.True(sut.CanRetryAfterFailure());
73+
74+
now = now.AddSeconds(5);
75+
Assert.False(sut.CanRetryAfterFailure());
76+
77+
now = now.AddSeconds(26);
78+
Assert.True(sut.CanRetryAfterFailure());
79+
}
80+
81+
[Fact]
82+
public void Should_reset_window()
83+
{
84+
var sut = new RetryWindow(TimeSpan.FromMinutes(1), 2, clock);
85+
86+
Assert.True(sut.CanRetryAfterFailure());
87+
Assert.True(sut.CanRetryAfterFailure());
88+
Assert.False(sut.CanRetryAfterFailure());
89+
90+
sut.Reset();
91+
92+
Assert.True(sut.CanRetryAfterFailure());
93+
Assert.True(sut.CanRetryAfterFailure());
94+
}
95+
96+
[Fact]
97+
public void Should_handle_windowSize_one()
98+
{
99+
var sut = new RetryWindow(TimeSpan.FromSeconds(10), 1, clock);
100+
101+
Assert.True(sut.CanRetryAfterFailure());
102+
Assert.False(sut.CanRetryAfterFailure());
103+
Assert.False(sut.CanRetryAfterFailure());
104+
105+
now = now.AddSeconds(11);
106+
107+
Assert.True(sut.CanRetryAfterFailure());
108+
}
109+
110+
[Fact]
111+
public void Should_maintain_queue_size_correctly()
112+
{
113+
var sut = new RetryWindow(TimeSpan.FromMinutes(5), 3, clock);
114+
115+
for (int i = 0; i < 10; i++)
116+
{
117+
sut.CanRetryAfterFailure();
118+
now = now.AddSeconds(1);
119+
}
120+
121+
now = now.AddMinutes(6);
122+
Assert.True(sut.CanRetryAfterFailure());
123+
}
124+
}
Lines changed: 61 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,61 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
2-
3-
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
5-
<LangVersion>Latest</LangVersion>
6-
<Nullable>enable</Nullable>
7-
<ImplicitUsings>enable</ImplicitUsings>
8-
<IsPackable>false</IsPackable>
9-
<RootNamespace>Squidex.Events</RootNamespace>
10-
<NeutralLanguage>en</NeutralLanguage>
11-
</PropertyGroup>
12-
13-
<ItemGroup>
14-
<None Remove="appSettings.json" />
15-
</ItemGroup>
16-
17-
<ItemGroup>
18-
<PackageReference Include="coverlet.collector" Version="6.0.4">
19-
<PrivateAssets>all</PrivateAssets>
20-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
21-
</PackageReference>
22-
<PackageReference Include="FakeItEasy" Version="8.3.0" />
23-
<PackageReference Include="FluentAssertions" Version="8.2.0" />
24-
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
25-
<PrivateAssets>all</PrivateAssets>
26-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
27-
</PackageReference>
28-
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.16" />
29-
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
30-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
31-
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
32-
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
33-
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
34-
<PackageReference Include="System.Text.Json" Version="8.0.6" />
35-
<PackageReference Include="Testcontainers.EventStoreDb" Version="4.1.0" />
36-
<PackageReference Include="xunit" Version="2.9.3" />
37-
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
38-
<PrivateAssets>all</PrivateAssets>
39-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
40-
</PackageReference>
41-
</ItemGroup>
42-
43-
<ItemGroup>
44-
<Using Include="FakeItEasy" />
45-
<Using Include="FluentAssertions" />
46-
<Using Include="Xunit" />
47-
</ItemGroup>
48-
49-
<ItemGroup>
50-
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
51-
</ItemGroup>
52-
53-
<ItemGroup>
54-
<Content Include="appSettings.Development.json">
55-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
56-
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
57-
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
58-
</Content>
59-
<Content Include="appSettings.json">
60-
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
61-
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
62-
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
63-
</Content>
64-
</ItemGroup>
65-
66-
<ItemGroup>
67-
<ProjectReference Include="..\..\utils\TestHelpers\TestHelpers.csproj" />
68-
<ProjectReference Include="..\Squidex.Events.EntityFramework\Squidex.Events.EntityFramework.csproj" />
69-
<ProjectReference Include="..\Squidex.Events.GetEventStore\Squidex.Events.GetEventStore.csproj" />
70-
<ProjectReference Include="..\Squidex.Events.Mongo\Squidex.Events.Mongo.csproj" />
71-
<ProjectReference Include="..\Squidex.Events\Squidex.Events.csproj" />
72-
</ItemGroup>
73-
74-
</Project>
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<LangVersion>Latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<IsPackable>false</IsPackable>
9+
<RootNamespace>Squidex.Events</RootNamespace>
10+
<NeutralLanguage>en</NeutralLanguage>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<None Remove="appSettings.json" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<PackageReference Include="coverlet.collector" Version="6.0.4">
19+
<PrivateAssets>all</PrivateAssets>
20+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
21+
</PackageReference>
22+
<PackageReference Include="FakeItEasy" Version="8.3.0" />
23+
<PackageReference Include="FluentAssertions" Version="8.2.0" />
24+
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
25+
<PrivateAssets>all</PrivateAssets>
26+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
27+
</PackageReference>
28+
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.16" />
29+
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
30+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
31+
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
32+
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
33+
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
34+
<PackageReference Include="System.Text.Json" Version="8.0.6" />
35+
<PackageReference Include="Testcontainers.EventStoreDb" Version="4.1.0" />
36+
<PackageReference Include="xunit" Version="2.9.3" />
37+
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
38+
<PrivateAssets>all</PrivateAssets>
39+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
40+
</PackageReference>
41+
</ItemGroup>
42+
43+
<ItemGroup>
44+
<Using Include="FakeItEasy" />
45+
<Using Include="FluentAssertions" />
46+
<Using Include="Xunit" />
47+
</ItemGroup>
48+
49+
<ItemGroup>
50+
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
51+
</ItemGroup>
52+
53+
<ItemGroup>
54+
<ProjectReference Include="..\..\utils\TestHelpers\TestHelpers.csproj" />
55+
<ProjectReference Include="..\Squidex.Events.EntityFramework\Squidex.Events.EntityFramework.csproj" />
56+
<ProjectReference Include="..\Squidex.Events.GetEventStore\Squidex.Events.GetEventStore.csproj" />
57+
<ProjectReference Include="..\Squidex.Events.Mongo\Squidex.Events.Mongo.csproj" />
58+
<ProjectReference Include="..\Squidex.Events\Squidex.Events.csproj" />
59+
</ItemGroup>
60+
61+
</Project>
Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,50 @@
1-
// ==========================================================================
2-
// Squidex Headless CMS
3-
// ==========================================================================
4-
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5-
// All rights reserved. Licensed under the MIT license.
6-
// ==========================================================================
7-
8-
namespace Squidex.Events.Utils;
9-
10-
public sealed class RetryWindow(TimeSpan windowDuration, int windowSize, TimeProvider? clock = null)
11-
{
12-
private readonly int windowSize = windowSize + 1;
13-
private readonly Queue<DateTimeOffset> retries = new Queue<DateTimeOffset>();
14-
private readonly TimeProvider clock = clock ?? TimeProvider.System;
15-
16-
public void Reset()
17-
{
18-
retries.Clear();
19-
}
20-
21-
public bool CanRetryAfterFailure()
22-
{
23-
var now = clock.GetUtcNow();
24-
25-
retries.Enqueue(now);
26-
27-
while (retries.Count > windowSize)
28-
{
29-
retries.Dequeue();
30-
}
31-
32-
return retries.Count < windowSize || (retries.Count > 0 && (now - retries.Peek()) > windowDuration);
33-
}
34-
}
1+
// ==========================================================================
2+
// Squidex Headless CMS
3+
// ==========================================================================
4+
// Copyright (c) Squidex UG (haftungsbeschraenkt)
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
namespace Squidex.Events.Utils;
9+
10+
public sealed class RetryWindow(TimeSpan windowDuration, int windowSize, TimeProvider? clock = null)
11+
{
12+
private readonly int windowToKeep = windowSize + 1;
13+
private readonly Queue<DateTimeOffset> retries = new Queue<DateTimeOffset>();
14+
private readonly TimeProvider clock = clock ?? TimeProvider.System;
15+
16+
public void Reset()
17+
{
18+
retries.Clear();
19+
}
20+
21+
public bool CanRetryAfterFailure()
22+
{
23+
var now = clock.GetUtcNow();
24+
25+
if (windowSize <= 0)
26+
{
27+
// First attempt is always allowed
28+
if (retries.Count == 0)
29+
{
30+
retries.Enqueue(now);
31+
return true;
32+
}
33+
34+
var last = retries.Dequeue();
35+
retries.Enqueue(now);
36+
return (now - last) > windowDuration;
37+
}
38+
39+
retries.Enqueue(now);
40+
while (retries.Count > windowToKeep)
41+
{
42+
retries.Dequeue();
43+
}
44+
45+
// Allow retry if:
46+
// 1. Haven't reached the window size limit yet, OR
47+
// 2. The oldest retry in the queue is older than windowDuration (window has "expired")
48+
return retries.Count < windowToKeep || (retries.Count > 0 && (now - retries.Peek()) > windowDuration);
49+
}
50+
}

0 commit comments

Comments
 (0)