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); + } + } +}