Skip to content

Commit 92e2995

Browse files
committed
Feat: Introduce ThrottledDebouncer with throttle control features
1 parent 7cb1c18 commit 92e2995

File tree

5 files changed

+143
-33
lines changed

5 files changed

+143
-33
lines changed

src/CodeOfChaos.Extensions/Debouncers/Regular/Debouncer.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed class Debouncer: DebouncerBase<Debouncer.EmptyUnit> {
1313
// -----------------------------------------------------------------------------------------------------------------
1414
// Constructors
1515
// -----------------------------------------------------------------------------------------------------------------
16+
private Debouncer() {}
1617
public static Debouncer FromDelegate(Action action, int debounceMs = DefaultDebounceMs)
1718
=> new() {
1819
Callback = action,

src/CodeOfChaos.Extensions/Debouncers/Regular/DebouncerBase.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,9 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
4141
_debounceTask = Task.Run(function: async () => {
4242
try {
4343
await Task.Delay(DebounceMs, debounceToken);
44-
45-
// If we weren't cancelled, execute the callback
46-
if (!debounceToken.IsCancellationRequested) {
47-
await InvokeCallbackAsync(_latestValue!, ct);
48-
}
44+
if (debounceToken.IsCancellationRequested) return;
45+
await InvokeCallbackAsync(_latestValue!, ct);
46+
4947
}
5048
catch (OperationCanceledException) {
5149
// Ignore
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// ---------------------------------------------------------------------------------------------------------------------
2+
// Imports
3+
// ---------------------------------------------------------------------------------------------------------------------
4+
namespace CodeOfChaos.Extensions.Debouncers;
5+
6+
// ---------------------------------------------------------------------------------------------------------------------
7+
// Code
8+
// ---------------------------------------------------------------------------------------------------------------------
9+
public sealed class ThrottledDebouncer: ThrottledDebouncerBase<ThrottledDebouncer.EmptyUnit> {
10+
public readonly struct EmptyUnit; // Workaround for reusing ThrottledDebouncerBase<T> instead of creating a fully new DebouncerBase without generics
11+
private object Callback { get; init; } = null!;
12+
13+
// -----------------------------------------------------------------------------------------------------------------
14+
// Constructors
15+
// -----------------------------------------------------------------------------------------------------------------
16+
private ThrottledDebouncer() {}
17+
public static ThrottledDebouncer FromDelegate(Action action, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
18+
=> new() {
19+
Callback = action,
20+
DebounceMs = debounceMs,
21+
ThrottleMs = throttleMs
22+
};
23+
24+
public static ThrottledDebouncer FromDelegate(Func<CancellationToken, Task> func, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
25+
=> new() {
26+
Callback = func,
27+
DebounceMs = debounceMs,
28+
ThrottleMs = throttleMs
29+
};
30+
31+
public static ThrottledDebouncer FromDelegate(Func<Task> func, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
32+
=> new() {
33+
Callback = func,
34+
DebounceMs = debounceMs,
35+
ThrottleMs = throttleMs
36+
};
37+
38+
// -----------------------------------------------------------------------------------------------------------------
39+
// Methods
40+
// -----------------------------------------------------------------------------------------------------------------
41+
public Task InvokeDebouncedAsync(CancellationToken ct = default)
42+
=> DebouncerLogicAsync(default, ct);
43+
44+
protected async override ValueTask InvokeCallbackAsync(EmptyUnit item, CancellationToken ct = default) {
45+
if (ct.IsCancellationRequested) return;
46+
47+
switch (Callback) {
48+
49+
case Action action: {
50+
action.Invoke();
51+
break;
52+
}
53+
54+
case Func<Task> func: {
55+
await func.Invoke();
56+
break;
57+
}
58+
59+
case Func<CancellationToken, Task> func: {
60+
await func.Invoke(ct);
61+
break;
62+
}
63+
64+
default: throw new InvalidOperationException("Invalid function type");
65+
}
66+
}
67+
}
68+
69+
public sealed class ThrottledDebouncer<T> : ThrottledDebouncerBase<T> {
70+
private object Callback { get; init; } = null!;
71+
72+
// -----------------------------------------------------------------------------------------------------------------
73+
// Constructors
74+
// -----------------------------------------------------------------------------------------------------------------
75+
private ThrottledDebouncer() {}
76+
public static ThrottledDebouncer<T> FromDelegate(Action<T> action, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
77+
=> new() {
78+
Callback = action,
79+
DebounceMs = debounceMs,
80+
ThrottleMs = throttleMs
81+
};
82+
83+
public static ThrottledDebouncer<T> FromDelegate(Func<T, Task> func, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
84+
=> new() {
85+
Callback = func,
86+
DebounceMs = debounceMs,
87+
ThrottleMs = throttleMs
88+
};
89+
90+
public static ThrottledDebouncer<T> FromDelegate(Func<T, CancellationToken, Task> func, int debounceMs = DefaultDebounceMs, int throttleMs = DefaultThrottleMs)
91+
=> new() {
92+
Callback = func,
93+
DebounceMs = debounceMs,
94+
ThrottleMs = throttleMs
95+
};
96+
97+
// -----------------------------------------------------------------------------------------------------------------
98+
// Methods
99+
// -----------------------------------------------------------------------------------------------------------------
100+
public Task InvokeDebouncedAsync(T item, CancellationToken ct = default)
101+
=> DebouncerLogicAsync(item, ct);
102+
103+
protected async override ValueTask InvokeCallbackAsync(T item, CancellationToken ct = default) {
104+
if (ct.IsCancellationRequested) return;
105+
106+
switch (Callback) {
107+
case Action<T> action: {
108+
action.Invoke(item);
109+
break;
110+
}
111+
112+
case Func<T, Task> func: {
113+
await func.Invoke(item);
114+
break;
115+
}
116+
117+
case Func<T, CancellationToken, Task> func: {
118+
await func.Invoke(item, ct);
119+
break;
120+
}
121+
122+
default: throw new InvalidOperationException("Invalid function type");
123+
}
124+
}
125+
}

src/CodeOfChaos.Extensions/Debouncers/Throttled/ThrottledDebouncerBase.cs

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,12 @@ namespace CodeOfChaos.Extensions.Debouncers;
66
// ---------------------------------------------------------------------------------------------------------------------
77
// Code
88
// ---------------------------------------------------------------------------------------------------------------------
9-
public abstract class ThrottledDebouncerBase(int debounceMs) : ThrottledDebouncerBase<ThrottledDebouncerBase.EmptyUnit>(debounceMs) {
10-
public readonly struct EmptyUnit;
11-
12-
// -----------------------------------------------------------------------------------------------------------------
13-
// Methods
14-
// -----------------------------------------------------------------------------------------------------------------
15-
protected abstract ValueTask InvokeCallbackAsync(CancellationToken ct = default);
16-
protected override ValueTask InvokeCallbackAsync(EmptyUnit unit, CancellationToken ct = default) => InvokeCallbackAsync(ct);
17-
}
18-
19-
public abstract class ThrottledDebouncerBase<T>(int debounceMs) : IAsyncDisposable {
9+
public abstract class ThrottledDebouncerBase<T> : IAsyncDisposable {
2010
protected const int DefaultDebounceMs = 100;
11+
protected const int DefaultThrottleMs = 100;
12+
13+
protected int DebounceMs { get; init; } = DefaultDebounceMs;
14+
protected int ThrottleMs { get; init; } = DefaultThrottleMs;
2115

2216
private readonly SemaphoreSlim _semaphore = new(1, 1);
2317
private CancellationTokenSource? _cts;
@@ -39,7 +33,6 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
3933
try {
4034
_latestValue = value;
4135

42-
// Cancel any existing debounce task
4336
if (_cts is not null) {
4437
await _cts.CancelAsync();
4538
_cts.Dispose();
@@ -54,21 +47,18 @@ protected async Task DebouncerLogicAsync(T? value = default, CancellationToken c
5447
DateTime now = DateTime.UtcNow;
5548
double timeSinceLastExecute = (now - _lastExecuteTime).TotalMilliseconds;
5649

57-
// If we haven't executed recently, execute immediately
58-
if (timeSinceLastExecute >= debounceMs) {
50+
if (_lastExecuteTime != DateTime.MinValue && timeSinceLastExecute >= ThrottleMs) {
5951
_lastExecuteTime = now;
6052
await InvokeCallbackAsync(_latestValue!, ct);
53+
return;
6154
}
62-
else {
63-
// Wait for the remaining time, then execute
64-
int remainingDelay = (int)(debounceMs - timeSinceLastExecute);
65-
await Task.Delay(remainingDelay, debounceToken);
66-
67-
if (!debounceToken.IsCancellationRequested) {
68-
_lastExecuteTime = DateTime.UtcNow;
69-
await InvokeCallbackAsync(_latestValue!, ct);
70-
}
71-
}
55+
56+
await Task.Delay(DebounceMs, debounceToken);
57+
58+
if (debounceToken.IsCancellationRequested) return;
59+
_lastExecuteTime = now;
60+
await InvokeCallbackAsync(_latestValue!, ct);
61+
7262
}
7363
catch (OperationCanceledException) {
7464
// Ignore cancellation

tests/Tests.CodeOfChaos.Extensions/Tests.CodeOfChaos.Extensions.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,4 @@
2121
<ProjectReference Include="..\..\src\CodeOfChaos.Extensions\CodeOfChaos.Extensions.csproj"/>
2222
</ItemGroup>
2323

24-
<ItemGroup>
25-
<Folder Include="Debouncers\Throttled\" />
26-
</ItemGroup>
27-
2824
</Project>

0 commit comments

Comments
 (0)