diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln
index 18a30a9af..6e4416d7d 100644
--- a/StackExchange.Redis.sln
+++ b/StackExchange.Redis.sln
@@ -103,31 +103,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{00CA0876-DA9
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "toys", "toys", "{E25031D3-5C64-430D-B86F-697B66816FD8}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{153A10E4-E668-41AD-9E0F-6785CE7EED66}"
- ProjectSection(SolutionItems) = preProject
- docs\Basics.md = docs\Basics.md
- docs\Configuration.md = docs\Configuration.md
- docs\Events.md = docs\Events.md
- docs\ExecSync.md = docs\ExecSync.md
- docs\index.md = docs\index.md
- docs\KeysScan.md = docs\KeysScan.md
- docs\KeysValues.md = docs\KeysValues.md
- docs\PipelinesMultiplexers.md = docs\PipelinesMultiplexers.md
- docs\Profiling.md = docs\Profiling.md
- docs\Profiling_v1.md = docs\Profiling_v1.md
- docs\Profiling_v2.md = docs\Profiling_v2.md
- docs\PubSubOrder.md = docs\PubSubOrder.md
- docs\ReleaseNotes.md = docs\ReleaseNotes.md
- docs\Resp3.md = docs\Resp3.md
- docs\RespLogging.md = docs\RespLogging.md
- docs\Scripting.md = docs\Scripting.md
- docs\Server.md = docs\Server.md
- docs\Testing.md = docs\Testing.md
- docs\ThreadTheft.md = docs\ThreadTheft.md
- docs\Timeouts.md = docs\Timeouts.md
- docs\Transactions.md = docs\Transactions.md
- EndProjectSection
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestConsoleBaseline", "toys\TestConsoleBaseline\TestConsoleBaseline.csproj", "{D58114AE-4998-4647-AFCA-9353D20495AE}"
EndProject
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
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleTestBaseline", "tests\ConsoleTestBaseline\ConsoleTestBaseline.csproj", "{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\docs.csproj", "{1DC43E76-5372-4C7F-A433-0602273E87FC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -192,6 +169,10 @@ Global
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1DC43E76-5372-4C7F-A433-0602273E87FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1DC43E76-5372-4C7F-A433-0602273E87FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1DC43E76-5372-4C7F-A433-0602273E87FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1DC43E76-5372-4C7F-A433-0602273E87FC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -209,7 +190,6 @@ Global
{D082703F-1652-4C35-840D-7D377F6B9979} = {96E891CD-2ED7-4293-A7AB-4C6F5D8D2B05}
{8375813E-FBAF-4DA3-A2C7-E4645B39B931} = {E25031D3-5C64-430D-B86F-697B66816FD8}
{3DA1EEED-E9FE-43D9-B293-E000CFCCD91A} = {E25031D3-5C64-430D-B86F-697B66816FD8}
- {153A10E4-E668-41AD-9E0F-6785CE7EED66} = {3AD17044-6BFF-4750-9AC2-2CA466375F2A}
{D58114AE-4998-4647-AFCA-9353D20495AE} = {E25031D3-5C64-430D-B86F-697B66816FD8}
{A9F81DA3-DA82-423E-A5DD-B11C37548E06} = {96E891CD-2ED7-4293-A7AB-4C6F5D8D2B05}
{A0F89B8B-32A3-4C28-8F1B-ADE343F16137} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
diff --git a/docs/AsyncTimeouts.md b/docs/AsyncTimeouts.md
new file mode 100644
index 000000000..5ba4fd3f1
--- /dev/null
+++ b/docs/AsyncTimeouts.md
@@ -0,0 +1,65 @@
+# Async timeouts and cancellation
+
+StackExchange.Redis directly supports timeout of *synchronous* operations, but for *asynchronous* operations, it is recommended
+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)
+family of methods. This allows the caller to control timeout (via `TimeSpan`), cancellation (via `CancellationToken`), or both.
+
+Note that it is possible that operations will still be buffered and may still be issued to the server *after* timeout/cancellation means
+that the caller isn't observing the result.
+
+## Usage
+
+### Timeout
+
+Timeouts are probably the most common cancellation scenario:
+
+```csharp
+var timeout = TimeSpan.FromSeconds(5);
+await database.StringSetAsync("key", "value").WaitAsync(timeout);
+var value = await database.StringGetAsync("key").WaitAsync(timeout);
+```
+
+### Cancellation
+
+You can also use `CancellationToken` to drive cancellation, identically:
+
+```csharp
+CancellationToken token = ...; // for example, from HttpContext.RequestAborted
+await database.StringSetAsync("key", "value").WaitAsync(token);
+var value = await database.StringGetAsync("key").WaitAsync(token);
+```
+### Combined Cancellation and Timeout
+
+These two concepts can be combined so that if either cancellation or timeout occur, the caller's
+operation is cancelled:
+
+```csharp
+var timeout = TimeSpan.FromSeconds(5);
+CancellationToken token = ...; // for example, from HttpContext.RequestAborted
+await database.StringSetAsync("key", "value").WaitAsync(timeout, token);
+var value = await database.StringGetAsync("key").WaitAsync(timeout, token);
+```
+
+### Creating a timeout for multiple operations
+
+If you want a timeout to apply to a *group* of operations rather than individually, then you
+can using `CancellationTokenSource` to create a `CancellationToken` that is cancelled after a
+specified timeout. For example:
+
+```csharp
+var timeout = TimeSpan.FromSeconds(5);
+using var cts = new CancellationTokenSource(timeout);
+await database.StringSetAsync("key", "value").WaitAsync(cts.Token);
+var value = await database.StringGetAsync("key").WaitAsync(cts.Token);
+```
+
+This can additionally be combined with one-or-more cancellation tokens:
+
+```csharp
+var timeout = TimeSpan.FromSeconds(5);
+CancellationToken token = ...; // for example, from HttpContext.RequestAborted
+using var cts = CancellationTokenSource.CreateLinkedTokenSource(token); // or multiple tokens
+cts.CancelAfter(timeout);
+await database.StringSetAsync("key", "value").WaitAsync(cts.Token);
+var value = await database.StringGetAsync("key").WaitAsync(cts.Token);
+``````
\ No newline at end of file
diff --git a/docs/docs.csproj b/docs/docs.csproj
new file mode 100644
index 000000000..977e065bc
--- /dev/null
+++ b/docs/docs.csproj
@@ -0,0 +1,6 @@
+
+
+
+ netstandard2.0
+
+
diff --git a/docs/index.md b/docs/index.md
index 40acca077..0b4d9bb2e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -32,6 +32,7 @@ Documentation
- [Server](Server) - running a redis server
- [Basic Usage](Basics) - getting started and basic usage
+- [Async Timeouts](AsyncTimeouts) - async timeouts and cancellation
- [Configuration](Configuration) - options available when connecting to redis
- [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer?
- [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API
diff --git a/tests/StackExchange.Redis.Tests/CancellationTests.cs b/tests/StackExchange.Redis.Tests/CancellationTests.cs
new file mode 100644
index 000000000..58e33e3de
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/CancellationTests.cs
@@ -0,0 +1,198 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace StackExchange.Redis.Tests;
+
+#if !NET6_0_OR_GREATER
+internal static class TaskExtensions
+{
+ // suboptimal polyfill version of the .NET 6+ API; I'm not recommending this for production use,
+ // but it's good enough for tests
+ public static Task WaitAsync(this Task task, CancellationToken cancellationToken)
+ {
+ if (task.IsCompleted || !cancellationToken.CanBeCanceled) return task;
+ return Wrap(task, cancellationToken);
+
+ static async Task Wrap(Task task, CancellationToken cancellationToken)
+ {
+ var tcs = new TaskCompletionSource();
+ using var reg = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));
+ _ = task.ContinueWith(t =>
+ {
+ if (t.IsCanceled) tcs.TrySetCanceled();
+ else if (t.IsFaulted) tcs.TrySetException(t.Exception!);
+ else tcs.TrySetResult(t.Result);
+ });
+ return await tcs.Task;
+ }
+ }
+
+ public static Task WaitAsync(this Task task, TimeSpan timeout)
+ {
+ if (task.IsCompleted) return task;
+ return Wrap(task, timeout);
+
+ static async Task Wrap(Task task, TimeSpan timeout)
+ {
+ Task other = Task.Delay(timeout);
+ var first = await Task.WhenAny(task, other);
+ if (ReferenceEquals(first, other))
+ {
+ throw new TimeoutException();
+ }
+ return await task;
+ }
+ }
+}
+#endif
+
+[Collection(SharedConnectionFixture.Key)]
+public class CancellationTests : TestBase
+{
+ public CancellationTests(ITestOutputHelper output, SharedConnectionFixture fixture) : base(output, fixture) { }
+
+ [Fact]
+ public async Task WithCancellation_CancelledToken_ThrowsOperationCanceledException()
+ {
+ using var conn = Create();
+ var db = conn.GetDatabase();
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel(); // Cancel immediately
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await db.StringSetAsync(Me(), "value").WaitAsync(cts.Token);
+ });
+ }
+
+ private IInternalConnectionMultiplexer Create() => Create(syncTimeout: 10_000);
+
+ [Fact]
+ public async Task WithCancellation_ValidToken_OperationSucceeds()
+ {
+ using var conn = Create();
+ var db = conn.GetDatabase();
+
+ using var cts = new CancellationTokenSource();
+
+ RedisKey key = Me();
+ // This should succeed
+ await db.StringSetAsync(key, "value");
+ var result = await db.StringGetAsync(key).WaitAsync(cts.Token);
+ Assert.Equal("value", result);
+ }
+
+ private void Pause(IDatabase db)
+ {
+ db.Execute("client", new object[] { "pause", ConnectionPauseMilliseconds }, CommandFlags.FireAndForget);
+ }
+
+ [Fact]
+ public async Task WithTimeout_ShortTimeout_Async_ThrowsOperationCanceledException()
+ {
+ using var conn = Create();
+ var db = conn.GetDatabase();
+
+ var watch = Stopwatch.StartNew();
+ Pause(db);
+
+ var timeout = TimeSpan.FromMilliseconds(ShortDelayMilliseconds);
+ // This might throw due to timeout, but let's test the mechanism
+ var pending = db.StringSetAsync(Me(), "value").WaitAsync(timeout); // check we get past this
+ try
+ {
+ await pending;
+ // If it succeeds, that's fine too - Redis is fast
+ Assert.Fail(ExpectedCancel + ": " + watch.ElapsedMilliseconds + "ms");
+ }
+ catch (TimeoutException)
+ {
+ // Expected for very short timeouts
+ Log($"Timeout after {watch.ElapsedMilliseconds}ms");
+ }
+ }
+
+ private const string ExpectedCancel = "This operation should have been cancelled";
+
+ [Fact]
+ public async Task WithoutCancellation_OperationsWorkNormally()
+ {
+ using var conn = Create();
+ var db = conn.GetDatabase();
+
+ // No cancellation - should work normally
+ RedisKey key = Me();
+ await db.StringSetAsync(key, "value");
+ var result = await db.StringGetAsync(key);
+ Assert.Equal("value", result);
+ }
+
+ public enum CancelStrategy
+ {
+ Constructor,
+ Method,
+ Manual,
+ }
+
+ private const int ConnectionPauseMilliseconds = 50, ShortDelayMilliseconds = 5;
+
+ private static CancellationTokenSource CreateCts(CancelStrategy strategy)
+ {
+ switch (strategy)
+ {
+ case CancelStrategy.Constructor:
+ return new CancellationTokenSource(TimeSpan.FromMilliseconds(ShortDelayMilliseconds));
+ case CancelStrategy.Method:
+ var cts = new CancellationTokenSource();
+ cts.CancelAfter(TimeSpan.FromMilliseconds(ShortDelayMilliseconds));
+ return cts;
+ case CancelStrategy.Manual:
+ cts = new();
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(ShortDelayMilliseconds);
+ // ReSharper disable once MethodHasAsyncOverload - TFM-dependent
+ cts.Cancel();
+ });
+ return cts;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(strategy));
+ }
+ }
+
+ [Theory]
+ [InlineData(CancelStrategy.Constructor)]
+ [InlineData(CancelStrategy.Method)]
+ [InlineData(CancelStrategy.Manual)]
+ public async Task CancellationDuringOperation_Async_CancelsGracefully(CancelStrategy strategy)
+ {
+ using var conn = Create();
+ var db = conn.GetDatabase();
+
+ var watch = Stopwatch.StartNew();
+ Pause(db);
+
+ // Cancel after a short delay
+ using var cts = CreateCts(strategy);
+
+ // Start an operation and cancel it mid-flight
+ var pending = db.StringSetAsync($"{Me()}:{strategy}", "value").WaitAsync(cts.Token);
+
+ try
+ {
+ await pending;
+ Assert.Fail(ExpectedCancel + ": " + watch.ElapsedMilliseconds + "ms");
+ }
+ catch (OperationCanceledException oce)
+ {
+ // Expected if cancellation happens during operation
+ Log($"Cancelled after {watch.ElapsedMilliseconds}ms");
+ Assert.Equal(cts.Token, oce.CancellationToken);
+ }
+ }
+}