Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 6 additions & 26 deletions StackExchange.Redis.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
65 changes: 65 additions & 0 deletions docs/AsyncTimeouts.md
Original file line number Diff line number Diff line change
@@ -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);
``````
6 changes: 6 additions & 0 deletions docs/docs.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Project Sdk="Microsoft.Build.NoTargets/3.3.0">
<!-- this is actually here just to serve as a hub for docs, so we don't need to keep editing the sln-->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
</Project>
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
198 changes: 198 additions & 0 deletions tests/StackExchange.Redis.Tests/CancellationTests.cs
Original file line number Diff line number Diff line change
@@ -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<T> WaitAsync<T>(this Task<T> task, CancellationToken cancellationToken)
{
if (task.IsCompleted || !cancellationToken.CanBeCanceled) return task;
return Wrap(task, cancellationToken);

static async Task<T> Wrap(Task<T> task, CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<T>();
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<T> WaitAsync<T>(this Task<T> task, TimeSpan timeout)
{
if (task.IsCompleted) return task;
return Wrap(task, timeout);

static async Task<T> Wrap(Task<T> 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<OperationCanceledException>(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);
}
}
}
Loading