Skip to content

Commit 6f98d47

Browse files
committed
docs: offer guidance on using framework methods for async timeouts / cancellation (#2910)
1 parent 47e3f93 commit 6f98d47

File tree

5 files changed

+276
-26
lines changed

5 files changed

+276
-26
lines changed

StackExchange.Redis.sln

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -103,31 +103,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{00CA0876-DA9
103103
EndProject
104104
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "toys", "toys", "{E25031D3-5C64-430D-B86F-697B66816FD8}"
105105
EndProject
106-
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{153A10E4-E668-41AD-9E0F-6785CE7EED66}"
107-
ProjectSection(SolutionItems) = preProject
108-
docs\Basics.md = docs\Basics.md
109-
docs\Configuration.md = docs\Configuration.md
110-
docs\Events.md = docs\Events.md
111-
docs\ExecSync.md = docs\ExecSync.md
112-
docs\index.md = docs\index.md
113-
docs\KeysScan.md = docs\KeysScan.md
114-
docs\KeysValues.md = docs\KeysValues.md
115-
docs\PipelinesMultiplexers.md = docs\PipelinesMultiplexers.md
116-
docs\Profiling.md = docs\Profiling.md
117-
docs\Profiling_v1.md = docs\Profiling_v1.md
118-
docs\Profiling_v2.md = docs\Profiling_v2.md
119-
docs\PubSubOrder.md = docs\PubSubOrder.md
120-
docs\ReleaseNotes.md = docs\ReleaseNotes.md
121-
docs\Resp3.md = docs\Resp3.md
122-
docs\RespLogging.md = docs\RespLogging.md
123-
docs\Scripting.md = docs\Scripting.md
124-
docs\Server.md = docs\Server.md
125-
docs\Testing.md = docs\Testing.md
126-
docs\ThreadTheft.md = docs\ThreadTheft.md
127-
docs\Timeouts.md = docs\Timeouts.md
128-
docs\Transactions.md = docs\Transactions.md
129-
EndProjectSection
130-
EndProject
131106
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestConsoleBaseline", "toys\TestConsoleBaseline\TestConsoleBaseline.csproj", "{D58114AE-4998-4647-AFCA-9353D20495AE}"
132107
EndProject
133108
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = ".github", ".github\.github.csproj", "{8FB98E7D-DAE2-4465-BD9A-104000E0A2D4}"
@@ -142,6 +117,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleTest", "tests\Consol
142117
EndProject
143118
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleTestBaseline", "tests\ConsoleTestBaseline\ConsoleTestBaseline.csproj", "{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}"
144119
EndProject
120+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\docs.csproj", "{1DC43E76-5372-4C7F-A433-0602273E87FC}"
121+
EndProject
145122
Global
146123
GlobalSection(SolutionConfigurationPlatforms) = preSolution
147124
Debug|Any CPU = Debug|Any CPU
@@ -192,6 +169,10 @@ Global
192169
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
193170
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
194171
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Release|Any CPU.Build.0 = Release|Any CPU
172+
{1DC43E76-5372-4C7F-A433-0602273E87FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
173+
{1DC43E76-5372-4C7F-A433-0602273E87FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
174+
{1DC43E76-5372-4C7F-A433-0602273E87FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
175+
{1DC43E76-5372-4C7F-A433-0602273E87FC}.Release|Any CPU.Build.0 = Release|Any CPU
195176
EndGlobalSection
196177
GlobalSection(SolutionProperties) = preSolution
197178
HideSolutionNode = FALSE
@@ -209,7 +190,6 @@ Global
209190
{D082703F-1652-4C35-840D-7D377F6B9979} = {96E891CD-2ED7-4293-A7AB-4C6F5D8D2B05}
210191
{8375813E-FBAF-4DA3-A2C7-E4645B39B931} = {E25031D3-5C64-430D-B86F-697B66816FD8}
211192
{3DA1EEED-E9FE-43D9-B293-E000CFCCD91A} = {E25031D3-5C64-430D-B86F-697B66816FD8}
212-
{153A10E4-E668-41AD-9E0F-6785CE7EED66} = {3AD17044-6BFF-4750-9AC2-2CA466375F2A}
213193
{D58114AE-4998-4647-AFCA-9353D20495AE} = {E25031D3-5C64-430D-B86F-697B66816FD8}
214194
{A9F81DA3-DA82-423E-A5DD-B11C37548E06} = {96E891CD-2ED7-4293-A7AB-4C6F5D8D2B05}
215195
{A0F89B8B-32A3-4C28-8F1B-ADE343F16137} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}

docs/AsyncTimeouts.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Async timeouts and cancellation
2+
3+
StackExchange.Redis directly supports timeout of *synchronous* operations, but for *asynchronous* operations, it is recommended
4+
to use the inbuilt framework support for cancellation and timeouts, i.e. the [WaitAsync](https://learn.microsoft.com/dotnet/api/system.threading.tasks.task.waitasync)
5+
family of methods. This allows the caller to control timeout (via `TimeSpan`), cancellation (via `CancellationToken`), or both.
6+
7+
Note that it is possible that operations will still be buffered and may still be issued to the server *after* timeout/cancellation means
8+
that the caller isn't observing the result.
9+
10+
## Usage
11+
12+
### Timeout
13+
14+
Timeouts are probably the most common cancellation scenario:
15+
16+
```csharp
17+
var timeout = TimeSpan.FromSeconds(5);
18+
await database.StringSetAsync("key", "value").WaitAsync(timeout);
19+
var value = await database.StringGetAsync("key").WaitAsync(timeout);
20+
```
21+
22+
### Cancellation
23+
24+
You can also use `CancellationToken` to drive cancellation, identically:
25+
26+
```csharp
27+
CancellationToken token = ...; // for example, from HttpContext.RequestAborted
28+
await database.StringSetAsync("key", "value").WaitAsync(token);
29+
var value = await database.StringGetAsync("key").WaitAsync(token);
30+
```
31+
### Combined Cancellation and Timeout
32+
33+
These two concepts can be combined so that if either cancellation or timeout occur, the caller's
34+
operation is cancelled:
35+
36+
```csharp
37+
var timeout = TimeSpan.FromSeconds(5);
38+
CancellationToken token = ...; // for example, from HttpContext.RequestAborted
39+
await database.StringSetAsync("key", "value").WaitAsync(timeout, token);
40+
var value = await database.StringGetAsync("key").WaitAsync(timeout, token);
41+
```
42+
43+
### Creating a timeout for multiple operations
44+
45+
If you want a timeout to apply to a *group* of operations rather than individually, then you
46+
can using `CancellationTokenSource` to create a `CancellationToken` that is cancelled after a
47+
specified timeout. For example:
48+
49+
```csharp
50+
var timeout = TimeSpan.FromSeconds(5);
51+
using var cts = new CancellationTokenSource(timeout);
52+
await database.StringSetAsync("key", "value").WaitAsync(cts.Token);
53+
var value = await database.StringGetAsync("key").WaitAsync(cts.Token);
54+
```
55+
56+
This can additionally be combined with one-or-more cancellation tokens:
57+
58+
```csharp
59+
var timeout = TimeSpan.FromSeconds(5);
60+
CancellationToken token = ...; // for example, from HttpContext.RequestAborted
61+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); // or multiple tokens
62+
cts.CancelAfter(timeout);
63+
await database.StringSetAsync("key", "value").WaitAsync(cts.Token);
64+
var value = await database.StringGetAsync("key").WaitAsync(cts.Token);
65+
``````

docs/docs.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<Project Sdk="Microsoft.Build.NoTargets/3.3.0">
2+
<!-- this is actually here just to serve as a hub for docs, so we don't need to keep editing the sln-->
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
</PropertyGroup>
6+
</Project>

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Documentation
3232

3333
- [Server](Server) - running a redis server
3434
- [Basic Usage](Basics) - getting started and basic usage
35+
- [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation
3536
- [Configuration](Configuration) - options available when connecting to redis
3637
- [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer?
3738
- [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Xunit;
6+
using Xunit.Abstractions;
7+
8+
namespace StackExchange.Redis.Tests;
9+
10+
#if !NET6_0_OR_GREATER
11+
internal static class TaskExtensions
12+
{
13+
// suboptimal polyfill version of the .NET 6+ API; I'm not recommending this for production use,
14+
// but it's good enough for tests
15+
public static Task<T> WaitAsync<T>(this Task<T> task, CancellationToken cancellationToken)
16+
{
17+
if (task.IsCompleted || !cancellationToken.CanBeCanceled) return task;
18+
return Wrap(task, cancellationToken);
19+
20+
static async Task<T> Wrap(Task<T> task, CancellationToken cancellationToken)
21+
{
22+
var tcs = new TaskCompletionSource<T>();
23+
using var reg = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
24+
_ = task.ContinueWith(t =>
25+
{
26+
if (t.IsCanceled) tcs.TrySetCanceled();
27+
else if (t.IsFaulted) tcs.TrySetException(t.Exception!);
28+
else tcs.TrySetResult(t.Result);
29+
});
30+
return await tcs.Task;
31+
}
32+
}
33+
34+
public static Task<T> WaitAsync<T>(this Task<T> task, TimeSpan timeout)
35+
{
36+
if (task.IsCompleted) return task;
37+
return Wrap(task, timeout);
38+
39+
static async Task<T> Wrap(Task<T> task, TimeSpan timeout)
40+
{
41+
Task other = Task.Delay(timeout);
42+
var first = await Task.WhenAny(task, other);
43+
if (ReferenceEquals(first, other))
44+
{
45+
throw new TimeoutException();
46+
}
47+
return await task;
48+
}
49+
}
50+
}
51+
#endif
52+
53+
[Collection(SharedConnectionFixture.Key)]
54+
public class CancellationTests : TestBase
55+
{
56+
public CancellationTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { }
57+
58+
[Fact]
59+
public async Task WithCancellation_CancelledToken_ThrowsOperationCanceledException()
60+
{
61+
using var conn = Create();
62+
var db = conn.GetDatabase();
63+
64+
using var cts = new CancellationTokenSource();
65+
cts.Cancel(); // Cancel immediately
66+
67+
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
68+
{
69+
await db.StringSetAsync(Me(), "value").WaitAsync(cts.Token);
70+
});
71+
}
72+
73+
private IInternalConnectionMultiplexer Create() => Create(syncTimeout: 10_000);
74+
75+
[Fact]
76+
public async Task WithCancellation_ValidToken_OperationSucceeds()
77+
{
78+
using var conn = Create();
79+
var db = conn.GetDatabase();
80+
81+
using var cts = new CancellationTokenSource();
82+
83+
RedisKey key = Me();
84+
// This should succeed
85+
await db.StringSetAsync(key, "value");
86+
var result = await db.StringGetAsync(key).WaitAsync(cts.Token);
87+
Assert.Equal("value", result);
88+
}
89+
90+
private void Pause(IDatabase db)
91+
{
92+
db.Execute("client", new object[] { "pause", ConnectionPauseMilliseconds }, CommandFlags.FireAndForget);
93+
}
94+
95+
[Fact]
96+
public async Task WithTimeout_ShortTimeout_Async_ThrowsOperationCanceledException()
97+
{
98+
using var conn = Create();
99+
var db = conn.GetDatabase();
100+
101+
var watch = Stopwatch.StartNew();
102+
Pause(db);
103+
104+
var timeout = TimeSpan.FromMilliseconds(ShortDelayMilliseconds);
105+
// This might throw due to timeout, but let's test the mechanism
106+
var pending = db.StringSetAsync(Me(), "value").WaitAsync(timeout); // check we get past this
107+
try
108+
{
109+
await pending;
110+
// If it succeeds, that's fine too - Redis is fast
111+
Assert.Fail(ExpectedCancel + ": " + watch.ElapsedMilliseconds + "ms");
112+
}
113+
catch (TimeoutException)
114+
{
115+
// Expected for very short timeouts
116+
Log($"Timeout after {watch.ElapsedMilliseconds}ms");
117+
}
118+
}
119+
120+
private const string ExpectedCancel = "This operation should have been cancelled";
121+
122+
[Fact]
123+
public async Task WithoutCancellation_OperationsWorkNormally()
124+
{
125+
using var conn = Create();
126+
var db = conn.GetDatabase();
127+
128+
// No cancellation - should work normally
129+
RedisKey key = Me();
130+
await db.StringSetAsync(key, "value");
131+
var result = await db.StringGetAsync(key);
132+
Assert.Equal("value", result);
133+
}
134+
135+
public enum CancelStrategy
136+
{
137+
Constructor,
138+
Method,
139+
Manual,
140+
}
141+
142+
private const int ConnectionPauseMilliseconds = 50, ShortDelayMilliseconds = 5;
143+
144+
private static CancellationTokenSource CreateCts(CancelStrategy strategy)
145+
{
146+
switch (strategy)
147+
{
148+
case CancelStrategy.Constructor:
149+
return new CancellationTokenSource(TimeSpan.FromMilliseconds(ShortDelayMilliseconds));
150+
case CancelStrategy.Method:
151+
var cts = new CancellationTokenSource();
152+
cts.CancelAfter(TimeSpan.FromMilliseconds(ShortDelayMilliseconds));
153+
return cts;
154+
case CancelStrategy.Manual:
155+
cts = new();
156+
_ = Task.Run(async () =>
157+
{
158+
await Task.Delay(ShortDelayMilliseconds);
159+
// ReSharper disable once MethodHasAsyncOverload - TFM-dependent
160+
cts.Cancel();
161+
});
162+
return cts;
163+
default:
164+
throw new ArgumentOutOfRangeException(nameof(strategy));
165+
}
166+
}
167+
168+
[Theory]
169+
[InlineData(CancelStrategy.Constructor)]
170+
[InlineData(CancelStrategy.Method)]
171+
[InlineData(CancelStrategy.Manual)]
172+
public async Task CancellationDuringOperation_Async_CancelsGracefully(CancelStrategy strategy)
173+
{
174+
using var conn = Create();
175+
var db = conn.GetDatabase();
176+
177+
var watch = Stopwatch.StartNew();
178+
Pause(db);
179+
180+
// Cancel after a short delay
181+
using var cts = CreateCts(strategy);
182+
183+
// Start an operation and cancel it mid-flight
184+
var pending = db.StringSetAsync($"{Me()}:{strategy}", "value").WaitAsync(cts.Token);
185+
186+
try
187+
{
188+
await pending;
189+
Assert.Fail(ExpectedCancel + ": " + watch.ElapsedMilliseconds + "ms");
190+
}
191+
catch (OperationCanceledException oce)
192+
{
193+
// Expected if cancellation happens during operation
194+
Log($"Cancelled after {watch.ElapsedMilliseconds}ms");
195+
Assert.Equal(cts.Token, oce.CancellationToken);
196+
}
197+
}
198+
}

0 commit comments

Comments
 (0)